Initial commit
This commit is contained in:
+25
@@ -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
|
||||||
+3
@@ -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
|
||||||
@@ -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 ([email protected])` | **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.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>io.discdrop</groupId>
|
||||||
|
<artifactId>discdrop</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<name>DiscDrop</name>
|
||||||
|
<description>Follow MusicBrainz artists and track their release groups</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<compiler-plugin>3.13.0</compiler-plugin>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||||
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
|
<quarkus.platform.version>3.12.1</quarkus.platform.version>
|
||||||
|
<skipITs>true</skipITs>
|
||||||
|
<surefire-plugin.version>3.2.5</surefire-plugin.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>${quarkus.platform.artifact-id}</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-resteasy-reactive-qute</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-resteasy-reactive-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-client</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-hibernate-orm</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-hibernate-orm-panache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-jdbc-h2</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-scheduler</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-arc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5-mockito</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
<goal>generate-code</goal>
|
||||||
|
<goal>generate-code-tests</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>${compiler-plugin}</version>
|
||||||
|
<configuration>
|
||||||
|
<compilerArgs>
|
||||||
|
<arg>-parameters</arg>
|
||||||
|
</compilerArgs>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${surefire-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||||
|
<maven.home>${maven.home}</maven.home>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-failsafe-plugin</artifactId>
|
||||||
|
<version>${surefire-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||||
|
<maven.home>${maven.home}</maven.home>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>native</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>native</name>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-failsafe-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<skipITs>${skipITs}</skipITs>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<properties>
|
||||||
|
<quarkus.package.jar.type>native</quarkus.package.jar.type>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
</project>
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<String, String> update(MultivaluedMap<String, String> incoming,
|
||||||
|
MultivaluedMap<String, String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ArtistSearchResult.ArtistDto> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package io.discdrop.mbz.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ArtistSearchResult {
|
||||||
|
public int count;
|
||||||
|
public int offset;
|
||||||
|
public List<ArtistDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReleaseGroupDto> 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<String> secondaryTypes;
|
||||||
|
@JsonProperty("artist-credit")
|
||||||
|
public List<ArtistCredit> artistCredit;
|
||||||
|
|
||||||
|
public static class ArtistCredit {
|
||||||
|
public String name;
|
||||||
|
public String joinphrase;
|
||||||
|
public Artist artist;
|
||||||
|
|
||||||
|
public static class Artist {
|
||||||
|
public String id;
|
||||||
|
public String name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ArtistTypeSetting> findByArtist(FollowedArtist artist) {
|
||||||
|
return list("artist", artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReleaseGroupBrowseResult.ReleaseGroupDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FollowedArtist> 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<ArtistTypeSetting> 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<ReleaseGroupEntity> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReleaseGroupEntity> 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<ReleaseGroupEntity> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReleaseGroupEntity> entities = feedService.latest(itemCount);
|
||||||
|
List<RssItem> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ArtistSearchResult.ArtistDto> 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<String> followedMbids = FollowedArtist.<FollowedArtist>streamAll()
|
||||||
|
.map(a -> a.mbid)
|
||||||
|
.toList();
|
||||||
|
return fragments_search_dropdown.data("results", results)
|
||||||
|
.data("followedMbids", followedMbids)
|
||||||
|
.data("query", q);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Integer> SCHEDULE_OPTIONS = List.of(6, 12, 24);
|
||||||
|
|
||||||
|
@GET
|
||||||
|
public TemplateInstance panel() {
|
||||||
|
return buildPanel(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
public TemplateInstance save(@FormParam("defaultPrimaryTypes") Set<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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<FollowedArtist> allFollowed() {
|
||||||
|
return FollowedArtist.listAll(Sort.by("name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ArtistTypeSetting> settingsFor(FollowedArtist artist) {
|
||||||
|
return ArtistTypeSetting.findByArtist(artist);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ArtistSearchResult.ArtistDto> search(String query) {
|
||||||
|
return mbzService.searchArtists(query, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReleaseGroupEntity> orderedQuery() {
|
||||||
|
return ReleaseGroupEntity.find(ORDER_HQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ReleaseGroupEntity> feedPage(int offset) {
|
||||||
|
if (offset < 0) {
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
return orderedQuery().range(offset, offset + pageSize - 1).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ReleaseGroupEntity> latest(int count) {
|
||||||
|
return orderedQuery().range(0, Math.max(0, count - 1)).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int pageSize() {
|
||||||
|
return pageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> ALL_PRIMARY_TYPES = List.of("album", "single", "ep", "broadcast", "other");
|
||||||
|
|
||||||
|
public Set<String> getDefaultPrimaryTypes() {
|
||||||
|
String raw = AppSetting.get(KEY_DEFAULT_PRIMARY_TYPES, "album");
|
||||||
|
return parseTypes(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDefaultPrimaryTypes(Set<String> 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<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ([email protected])
|
||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/* DiscDrop custom styles */
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
#search-dropdown:empty { display: none; }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#0b0f19"/>
|
||||||
|
<circle cx="14" cy="14" r="10" fill="#1f2937"/>
|
||||||
|
<circle cx="14" cy="14" r="6.5" fill="none" stroke="#374151" stroke-width="0.9"/>
|
||||||
|
<circle cx="14" cy="14" r="3.2" fill="#6d28d9"/>
|
||||||
|
<circle cx="14" cy="14" r="0.9" fill="#0b0f19"/>
|
||||||
|
<path d="M22 21 L29 30" stroke="#6d28d9" stroke-width="2.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 469 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="DiscDrop">
|
||||||
|
<circle cx="20" cy="20" r="16" fill="#1f2937" stroke="#0b0f19" stroke-width="1.5"/>
|
||||||
|
<circle cx="20" cy="20" r="12" fill="none" stroke="#374151" stroke-width="1"/>
|
||||||
|
<circle cx="20" cy="20" r="8" fill="none" stroke="#374151" stroke-width="1"/>
|
||||||
|
<circle cx="20" cy="20" r="5" fill="#6d28d9"/>
|
||||||
|
<circle cx="20" cy="20" r="1.4" fill="#0b0f19"/>
|
||||||
|
<path d="M34 30 L42 42" stroke="#6d28d9" stroke-width="2.4" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
<path d="M30 34 L38 44" stroke="#6d28d9" stroke-width="2.4" stroke-linecap="round" opacity="0.35"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<rect width="64" height="64" rx="6" fill="#e5e7eb"/>
|
||||||
|
<circle cx="32" cy="32" r="20" fill="none" stroke="#9ca3af" stroke-width="2"/>
|
||||||
|
<circle cx="32" cy="32" r="8" fill="none" stroke="#9ca3af" stroke-width="2"/>
|
||||||
|
<text x="32" y="36" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#6b7280">no art</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 418 B |
@@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>DiscDrop – Artists</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/[email protected]"></script>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
<script src="/static/js/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300">
|
||||||
|
<div class="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img src="/static/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
|
||||||
|
<span class="text-xl font-bold">DiscDrop</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/" class="btn btn-ghost btn-sm">Feed</a>
|
||||||
|
<button id="theme-switcher" class="btn btn-ghost btn-circle" aria-label="Toggle theme" onclick="DiscDrop.toggleTheme()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-4 py-6 space-y-3">
|
||||||
|
<h1 class="text-2xl font-bold mb-2">Followed Artists</h1>
|
||||||
|
{#if artists.empty}
|
||||||
|
<div class="text-center text-base-content/60 py-12">
|
||||||
|
<p>You're not following any artists yet.</p>
|
||||||
|
<a href="/" class="link">Go to the feed to search and follow artists.</a>
|
||||||
|
</div>
|
||||||
|
{#else}
|
||||||
|
{#for artist in artists}
|
||||||
|
<div hx-get="/artists/{artist.mbid}/row" hx-trigger="revealed" hx-swap="innerHTML">
|
||||||
|
<div class="loading loading-spinner loading-sm"></div>
|
||||||
|
</div>
|
||||||
|
{/for}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<div class="bg-base-100 rounded-box border border-base-300 p-3 shadow-sm space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-semibold truncate">{artist.name}</div>
|
||||||
|
<div class="text-xs text-base-content/60 truncate">
|
||||||
|
{#if artist.disambiguation??}{artist.disambiguation}{#else}Artist{/if}
|
||||||
|
{#if artist.areaName??}<span class="badge badge-ghost badge-xs ml-1">{artist.areaName}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-xs text-error"
|
||||||
|
hx-delete="/artists/{artist.mbid}"
|
||||||
|
hx-target="closest div.space-y-3"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-on::after-request="DiscDrop.refreshFeed()">
|
||||||
|
Unfollow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#for s in settings}
|
||||||
|
<label class="label cursor-pointer gap-1 badge badge-outline py-2">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="checkbox checkbox-xs"
|
||||||
|
value="true"
|
||||||
|
{#if s.enabled}checked{/if}
|
||||||
|
hx-post="/artists/{artist.mbid}/types"
|
||||||
|
hx-vals='{"primaryType":"{s.primaryType}"}'
|
||||||
|
hx-include="this"
|
||||||
|
hx-target="#feed-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="DiscDrop.refreshFeed()"/>
|
||||||
|
<span class="text-xs">{s.primaryType}</span>
|
||||||
|
</label>
|
||||||
|
{/for}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{#for row in rows}
|
||||||
|
{#include fragments/feed-row.html row=row /}
|
||||||
|
{/for}
|
||||||
|
{#if hasMore}
|
||||||
|
<button id="load-more"
|
||||||
|
class="btn btn-outline btn-block mt-2"
|
||||||
|
hx-get="/feed?offset={nextOffset}"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if !hasMore && rows.empty}
|
||||||
|
<div class="text-center text-base-content/60 py-12">
|
||||||
|
<p class="text-lg">No releases yet.</p>
|
||||||
|
<p class="text-sm">Search for an artist above and click <strong>Follow</strong> to start tracking their release groups.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<div class="flex gap-3 items-center bg-base-100 rounded-box border border-base-300 p-3 shadow-sm">
|
||||||
|
<img src="https://coverartarchive.org/release-group/{row.mbid}/front"
|
||||||
|
alt="cover"
|
||||||
|
loading="lazy"
|
||||||
|
class="w-16 h-16 rounded object-cover bg-base-200"
|
||||||
|
onerror="this.onerror=null;this.src='/static/img/no-cover.svg'"/>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-semibold truncate">{row.artist.name}</span>
|
||||||
|
{#if row.primaryType??}<span class="badge badge-primary badge-sm">{row.primaryType}</span>{/if}
|
||||||
|
{#if row.secondaryTypes??}<span class="badge badge-ghost badge-sm">{row.secondaryTypes}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<a href="{row.mbzUrl}" target="_blank" rel="noopener" class="link link-hover text-base-content/90 block truncate">
|
||||||
|
{row.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right text-sm text-base-content/70 shrink-0">
|
||||||
|
{#if row.firstReleaseDateRaw??}{row.firstReleaseDateRaw}{#else}—{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{#if results.empty}
|
||||||
|
{#if query?? && query.length() >= 2}
|
||||||
|
<div class="p-3 text-sm text-base-content/60">No artists found for "{query}".</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#for a in results}
|
||||||
|
<div class="flex items-center gap-2 p-2 hover:bg-base-200 rounded">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium truncate">{a.name}</div>
|
||||||
|
<div class="text-xs text-base-content/60 truncate">
|
||||||
|
{#if a.disambiguation??}{a.disambiguation}{#else}Artist{/if}
|
||||||
|
{#if a.area?? && a.area.name??}<span class="badge badge-ghost badge-xs ml-1">{a.area.name}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if followedMbids.contains(a.id)}
|
||||||
|
<form hx-delete="/artists/{a.id}"
|
||||||
|
hx-target="#feed-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="DiscDrop.clearSearch()">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs text-error">Unfollow</button>
|
||||||
|
</form>
|
||||||
|
{#else}
|
||||||
|
<form hx-post="/artists/follow"
|
||||||
|
hx-target="#feed-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="DiscDrop.clearSearch()">
|
||||||
|
<input type="hidden" name="mbid" value="{a.id}"/>
|
||||||
|
<input type="hidden" name="name" value="{a.name}"/>
|
||||||
|
<input type="hidden" name="disambiguation" value="{a.disambiguation}"/>
|
||||||
|
<input type="hidden" name="type" value="{a.type}"/>
|
||||||
|
<input type="hidden" name="country" value="{a.country}"/>
|
||||||
|
{#if a.area?? && a.area.name??}
|
||||||
|
<input type="hidden" name="area" value="{a.area.name}"/>
|
||||||
|
{/if}
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Follow</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/for}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="font-bold text-lg">Settings</h3>
|
||||||
|
|
||||||
|
{#if flash == "saved"}
|
||||||
|
<div class="alert alert-success py-1 text-sm">Saved</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form hx-post="/settings" hx-target="#settings-panel-host" hx-swap="innerHTML" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold mb-1">Default primary types for new artists</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#for t in allPrimaryTypes}
|
||||||
|
<label class="label cursor-pointer gap-1 badge badge-outline py-2">
|
||||||
|
<input type="checkbox" name="defaultPrimaryTypes" value="{t}"
|
||||||
|
class="checkbox checkbox-xs"
|
||||||
|
{#if defaultPrimaryTypes.contains(t)}checked{/if}/>
|
||||||
|
<span class="text-xs">{t}</span>
|
||||||
|
</label>
|
||||||
|
{/for}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold mb-1">Feed update schedule</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#for h in scheduleOptions}
|
||||||
|
<label class="label cursor-pointer gap-1">
|
||||||
|
<input type="radio" name="syncScheduleHours" value="{h}"
|
||||||
|
class="radio radio-sm"
|
||||||
|
{#if syncScheduleHours == h}checked{/if}/>
|
||||||
|
<span class="text-xs">{h}h</span>
|
||||||
|
</label>
|
||||||
|
{/for}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary btn-sm w-full">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>DiscDrop</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg"/>
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="DiscDrop – New Releases" href="/rss"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/[email protected]"></script>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||||
|
<script src="/static/js/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300">
|
||||||
|
<div class="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img src="/static/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
|
||||||
|
<span class="text-xl font-bold">DiscDrop</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="search-wrap" class="relative flex-1 min-w-[220px] max-w-xl">
|
||||||
|
<form id="search-form" class="join w-full" autocomplete="off">
|
||||||
|
<input
|
||||||
|
id="search-input"
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
placeholder="Search MusicBrainz artists…"
|
||||||
|
class="input input-bordered join-item w-full"
|
||||||
|
hx-get="/search/artists"
|
||||||
|
hx-trigger="input changed delay:300ms"
|
||||||
|
hx-target="#search-dropdown"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-include="this"/>
|
||||||
|
</form>
|
||||||
|
<div id="search-dropdown"
|
||||||
|
class="absolute left-0 right-0 z-40 mt-1 bg-base-100 rounded-box shadow-lg border border-base-300 max-h-96 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="dropdown" id="settings-dropdown">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle" aria-label="Settings"
|
||||||
|
onclick="document.getElementById('settings-dropdown').classList.toggle('dropdown-open')">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.428 1.756 2.925 0 3.353a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.428 1.756-2.925 1.756-3.353 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.428-1.756-2.925 0-3.353a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div id="settings-panel-host"
|
||||||
|
class="dropdown-content z-40 mt-2 p-4 shadow bg-base-100 rounded-box w-80 border border-base-300"
|
||||||
|
hx-get="/settings" hx-trigger="revealed" hx-target="this" hx-swap="innerHTML">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="theme-switcher" class="btn btn-ghost btn-circle" aria-label="Toggle theme" onclick="DiscDrop.toggleTheme()">
|
||||||
|
<svg id="theme-icon-moon" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
|
||||||
|
<svg id="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="/artists" class="btn btn-ghost btn-sm" aria-label="Artists">Artists</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-4 py-6">
|
||||||
|
<section id="feed" class="space-y-3">
|
||||||
|
<div id="feed-list">
|
||||||
|
{#include fragments/feed-list.html rows=rows nextOffset=nextOffset hasMore=hasMore pageSize=pageSize /}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
|
||||||
|
<channel>
|
||||||
|
<title>DiscDrop – New Releases</title>
|
||||||
|
<link>http://localhost:8080/</link>
|
||||||
|
<description>Release groups from the MusicBrainz artists you follow on DiscDrop.</description>
|
||||||
|
<lastBuildDate>{buildDate}</lastBuildDate>
|
||||||
|
{#for item in items}
|
||||||
|
<item>
|
||||||
|
<title>{item.title}</title>
|
||||||
|
<link>{item.link}</link>
|
||||||
|
<guid isPermaLink="true">{item.guid}</guid>
|
||||||
|
{#if item.pubDate??}
|
||||||
|
<pubDate>{item.pubDate}</pubDate>
|
||||||
|
{/if}
|
||||||
|
<description><![CDATA[ {item.description} ]]></description>
|
||||||
|
<media:content url="{item.mediaUrl}" type="image/jpeg" medium="image"/>
|
||||||
|
</item>
|
||||||
|
{/for}
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
Reference in New Issue
Block a user