commit ba82c0b6ff35a5c1f19d220440159b9c45fdc3e2 Author: droideparanoico Date: Thu Jul 2 15:29:16 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c49543a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Prevent local build artifacts from leaking into Docker +target/ +data/ +*.db + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store + +# Gitea +.gitea/ \ No newline at end of file diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..6bdce44 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,29 @@ +# This workflow builds and deploys DiscDrop whenever code is pushed to main. +# The runner has Docker socket access so it can manage containers on the host. +# See: https://docs.gitea.com/usage/actions + +name: Build & Deploy +run-name: Build ${{ gitea.sha.substring(0, 7) }} + +on: + push: + branches: [ main ] + +env: + IMAGE_NAME: discdrop + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build Docker image + run: | + docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} . + docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest + + - name: Clean up old images + run: docker image prune -f --filter "until=24h" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..141f57d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +target/ +data/ +*.db +*.db-journal +*.db-wal +*.db-shm +*.iml +.idea/ +.vscode/ +*.class +*.jar +*.log +.DS_Store +.gitea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae2545c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# DiscDrop — Quarkus multi-stage build + +# ─── 1. Build ───────────────────────────────────── +FROM maven:3-eclipse-temurin-21-alpine AS build +WORKDIR /build +COPY pom.xml ./ +RUN mvn dependency:go-offline -q 2>/dev/null || true +COPY src ./src +RUN mvn package -q -DskipTests + +# ─── 2. Runtime ─────────────────────────────────── +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +RUN apk add --no-cache sqlite +COPY --from=build /build/target/quarkus-app/ /app/ +EXPOSE 8080 + +VOLUME /app/data + +ENV DISC_DROP_DB_PATH=/app/data/discdrop.db +ENV DISC_DROP_CACHE_TTL=8 +ENV DISC_DROP_BASE_URL=http://localhost:8080 + +ENTRYPOINT ["java", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=20", "-jar", "/app/quarkus-run.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2dab72 --- /dev/null +++ b/README.md @@ -0,0 +1,258 @@ +# DiscDrop + +> Self-hosted RSS feed generator for [MusicBrainz](https://musicbrainz.org) release groups. + +Track your favourite artists and get a unified RSS feed of their release groups — albums, singles, EPs, and more. No duplicates (release groups, not individual releases), per-artist type monitoring, and a clean modern UI. + +Built with **Quarkus + HTMX + daisyUI**. + +--- + +## Features + +- **Unified RSS feed** — One feed for all tracked artists, ordered chronologically by first release date +- **Per-artist monitoring** — Choose which release types to track per artist (Album, Single, EP, Broadcast, Other) +- **Artist search** — Autocomplete search against the MusicBrainz API with disambiguation info +- **Release group deduplication** — Tracks release groups instead of individual releases (no duplicate vinyl/CD/digital entries) +- **OPML export** — Subscribe in any RSS reader +- **Theme switching** — daisyUI themes: Dark, Light, Synthwave, Retro, Dracula, Night, Dim, Nord +- **Settings** — Configurable cache TTL (6/12/24h) and default release types +- **Zero infrastructure** — SQLite single-file database, no external services needed + +--- + +## Quick Start + +### Prerequisites + +- Java 21+ +- Maven 3.9+ + +### Development + +```bash +# Run in dev mode with live reload +mvn quarkus:dev +``` + +The app starts at [http://localhost:8080](http://localhost:8080). + +### Production (JAR) + +```bash +# Build +mvn clean package + +# Run +java -jar target/quarkus-app/quarkus-run.jar +``` + +### Docker + +Multi-stage build — no host pre-build needed, Maven runs inside the container. + +```bash +# Build (from project root) +docker build -t discdrop . + +# Run +docker run -p 8080:8080 \ + -e DISC_DROP_BASE_URL=http://localhost:8080 \ + -v discdrop-data:/app/data \ + discdrop + +### Docker Compose + +```bash +docker compose up -d +``` +``` + +--- + +## Configuration + +All configuration is via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DISC_DROP_DB_PATH` | `data/discdrop.db` | Path to the SQLite database file | +| `DISC_DROP_CACHE_TTL` | `8` | Cache TTL in hours | +| `DISC_DROP_BASE_URL` | `http://localhost:8080` | Public URL for RSS feed links | +| `DISC_DROP_USER_AGENT` | `DiscDrop/1.0 (https://gitea.t3du.com/…)` | User-Agent sent to MusicBrainz API | + +The User-Agent header is sent with every MusicBrainz API request per [their etiquette](https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting#Provide_meaningful_User-Agent_strings). It can be changed via `DISC_DROP_USER_AGENT`. + +--- + +## Usage + +### 1. Add artists + +Use the search bar in the navbar (available on all pages) to find artists. Each result shows the artist name, disambiguation, and country/area. If an artist is already tracked, it shows "✓ Added". + +Click **+ Add** to start tracking an artist. DiscDrop immediately fetches their release groups from MusicBrainz and caches them locally. + +### 2. Configure monitoring + +On the **Artists** page, toggle release type checkboxes per artist: +- **Album** (monitored by default) +- **Single** +- **EP** +- **Broadcast** +- **Other** + +Changes take effect immediately. The feed updates on next refresh. + +### 3. Subscribe to the feed + +On the **Releases** page, use the **RSS Feed** button to get the feed URL: +- RSS: `http://your-instance/feed/rss.xml` +- OPML: `http://your-instance/feed/opml` + +The feed includes: +- Release title and artist name +- Cover art from Cover Art Archive +- Release type (Album, Single, etc.) +- First release date +- Link to MusicBrainz release group page + +### 4. Settings + +Click the ⚙️ icon in the navbar to configure: +- **Cache refresh interval** (6, 8, 12, or 24 hours) +- **Default release types** for newly added artists + +--- + +## Architecture + +``` +Browser (HTMX + daisyUI) RSS Reader + │ │ + │ HTML fragments │ RSS/XML + ▼ ▼ +┌──────────────────────────────────────┐ +│ Quarkus HTTP Server │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ Qute │ │ REST │ │ RSS │ │ +│ │ Pages │ │ API │ │ Feed │ │ +│ └───┬────┘ └───┬────┘ └───┬────┘ │ +│ └───────────┼────────────┘ │ +│ ▼ │ +│ Service Layer │ +│ ArtistService · ReleaseGroupService │ +│ FeedService · CacheService │ +│ │ │ │ +│ ┌──────┴──────┐ ┌─────┴──────┐ │ +│ │ SQLite │ │ MusicBrainz│ │ +│ │ (raw JDBC) │ │ REST │ │ +│ └─────────────┘ └────────────┘ │ +└──────────────────────────────────────┘ +``` + +### Stack + +| Layer | Technology | +|-------|-----------| +| Framework | Quarkus 3.x | +| Language | Java 21 | +| Templating | Qute + HTMX 2.x | +| CSS | daisyUI 4.x (Tailwind CSS) | +| Database | SQLite via raw JDBC | +| RSS | Rome (rometools) | +| Build | Maven | +| Deployment | Docker (JVM) | + +--- + +## API Endpoints + +### Pages + +| Endpoint | Description | +|----------|-------------| +| `GET /` | Releases feed page | +| `GET /artists` | Tracked artists management page | + +### HTMX API + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/artists/search?q=` | Artist search autocomplete (HTML fragment) | +| `POST` | `/api/artists/{mbid}/track` | Add artist to tracking | +| `DELETE` | `/api/artists/{mbid}` | Remove artist | +| `PATCH` | `/api/artists/{mbid}/monitor?type=` | Toggle release type monitoring | +| `GET` | `/api/artists/list` | Tracked artists list (HTML fragment) | +| `GET` | `/api/artists/feed-table` | Feed entries table (HTML fragment) | +| `GET` | `/api/settings/form` | Settings form (HTML fragment) | +| `PATCH` | `/api/settings` | Save settings | + +### RSS / Export + +| Endpoint | Description | +|----------|-------------| +| `GET` | `/feed/rss.xml` | RSS 2.0 feed | +| `GET` | `/feed/opml` | OPML subscription file | + +--- + +## Data Model + +Two SQLite tables: + +- **`tracked_artist`** — Artists you're tracking with per-type monitoring flags +- **`release_group_cache`** — Cached release groups from MusicBrainz +- **`settings`** — Key-value app settings (cache TTL, default types) + +Database file location is configurable via `DISC_DROP_DB_PATH`. + +--- + +## Comparison: mbz-rss-feeder → DiscDrop + +| Feature | mbz-rss-feeder (Flask) | DiscDrop (Quarkus) | +|---------|----------------------|-------------------| +| Multiple feeds | Yes | **Single unified feed** | +| Tracks | Individual releases | **Release groups** (no duplicates) | +| Artist search | Simple list | **Autocomplete** with disambiguation | +| Type filtering | None | **Per-artist monitoring** (Album/Single/EP etc.) | +| RSS order | Newest first | **Chronological ascending** by release date | +| Theme | Plain CSS | **daisyUI themes** (7 themes) | +| Cache | File-based | **Database-level TTL cache** | +| Persistence | YAML files | **SQLite** (zero infra) | + +--- + +## Development + +### Prerequisites + +- Java 21 (Corretto or OpenJDK) +- Apache Maven 3.9+ + +### Build + +```bash +mvn clean package +``` + +### Run tests + +```bash +mvn test +``` + +### Live reload + +```bash +mvn quarkus:dev +``` + +Changes to Java source and templates are hot-reloaded. + +--- + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b764ce1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3.8" +services: + discdrop: + build: . + container_name: discdrop + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - discdrop-data:/app/data + environment: + - DISC_DROP_BASE_URL=http://localhost:8080 + - DISC_DROP_CACHE_TTL=8 + - DISC_DROP_DB_PATH=/app/data/discdrop.db + - DISC_DROP_USER_AGENT=DiscDrop/1.0 (https://gitea.t3du.com/droideparanoico/discdrop) + +volumes: + discdrop-data: diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..256a632 --- /dev/null +++ b/pom.xml @@ -0,0 +1,132 @@ + + 4.0.0 + + com.discdrop + discdrop + 1.0.0-SNAPSHOT + jar + + + 3.37.1 + 3.13.0 + 21 + UTF-8 + UTF-8 + 3.5.2 + + + + + + io.quarkus + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-resteasy + + + + + io.quarkus + quarkus-resteasy-qute + + + + + io.quarkus + quarkus-resteasy-client + + + + + io.quarkus + quarkus-arc + + + + + org.xerial + sqlite-jdbc + 3.49.1.0 + + + + + com.rometools + rome + 2.1.0 + + + + + io.quarkus + quarkus-jackson + + + io.quarkus + quarkus-resteasy-client-jackson + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + 5.5.1 + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + 21 + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + diff --git a/src/main/java/com/discdrop/client/MusicBrainzClient.java b/src/main/java/com/discdrop/client/MusicBrainzClient.java new file mode 100644 index 0000000..22d424a --- /dev/null +++ b/src/main/java/com/discdrop/client/MusicBrainzClient.java @@ -0,0 +1,43 @@ +package com.discdrop.client; + +import com.discdrop.client.dto.ArtistDetailsResponse; +import com.discdrop.client.dto.ArtistSearchResponse; +import com.discdrop.client.dto.ReleaseGroupBrowseResponse; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@Path("/") +@RegisterRestClient(configKey = "musicbrainz") +@RegisterProvider(UserAgentFilter.class) +public interface MusicBrainzClient { + + @GET + @Path("artist/") + ArtistSearchResponse searchArtists( + @QueryParam("query") String query, + @QueryParam("limit") int limit, + @QueryParam("fmt") String fmt + ); + + @GET + @Path("artist/{id}") + ArtistDetailsResponse getArtistDetails( + @PathParam("id") String mbid, + @QueryParam("inc") String inc, + @QueryParam("fmt") String fmt + ); + + @GET + @Path("release-group") + ReleaseGroupBrowseResponse browseReleaseGroups( + @QueryParam("artist") String artistMbid, + @QueryParam("limit") int limit, + @QueryParam("offset") int offset, + @QueryParam("inc") String inc, + @QueryParam("fmt") String fmt + ); +} diff --git a/src/main/java/com/discdrop/client/UserAgentFilter.java b/src/main/java/com/discdrop/client/UserAgentFilter.java new file mode 100644 index 0000000..98ae711 --- /dev/null +++ b/src/main/java/com/discdrop/client/UserAgentFilter.java @@ -0,0 +1,21 @@ +package com.discdrop.client; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.ext.Provider; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import java.io.IOException; + +@Provider +@ApplicationScoped +public class UserAgentFilter implements ClientRequestFilter { + + @ConfigProperty(name = "discdrop.user-agent") + String userAgent; + + @Override + public void filter(ClientRequestContext requestContext) throws IOException { + requestContext.getHeaders().putSingle("User-Agent", userAgent); + } +} diff --git a/src/main/java/com/discdrop/client/dto/ArtistDetailsResponse.java b/src/main/java/com/discdrop/client/dto/ArtistDetailsResponse.java new file mode 100644 index 0000000..01d2f68 --- /dev/null +++ b/src/main/java/com/discdrop/client/dto/ArtistDetailsResponse.java @@ -0,0 +1,24 @@ +package com.discdrop.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ArtistDetailsResponse( + String id, + String name, + String disambiguation, + Area area, + String type, + String country, + List relations +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Area(String name) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Relation(String type, Url url) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Url(String resource) {} +} diff --git a/src/main/java/com/discdrop/client/dto/ArtistSearchResponse.java b/src/main/java/com/discdrop/client/dto/ArtistSearchResponse.java new file mode 100644 index 0000000..6200c4f --- /dev/null +++ b/src/main/java/com/discdrop/client/dto/ArtistSearchResponse.java @@ -0,0 +1,23 @@ +package com.discdrop.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ArtistSearchResponse( + List artists, + int count +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Artist( + String id, + String name, + String disambiguation, + Area area, + String type, + String country + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Area(String name) {} +} diff --git a/src/main/java/com/discdrop/client/dto/ReleaseGroupBrowseResponse.java b/src/main/java/com/discdrop/client/dto/ReleaseGroupBrowseResponse.java new file mode 100644 index 0000000..9a8b60e --- /dev/null +++ b/src/main/java/com/discdrop/client/dto/ReleaseGroupBrowseResponse.java @@ -0,0 +1,27 @@ +package com.discdrop.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ReleaseGroupBrowseResponse( + @JsonProperty("release-groups") List releaseGroups, + @JsonProperty("release-group-count") int releaseGroupCount +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record ReleaseGroup( + String id, + String title, + @JsonProperty("first-release-date") String firstReleaseDate, + @JsonProperty("primary-type") String primaryType, + @JsonProperty("secondary-types") List secondaryTypes, + @JsonProperty("artist-credit") List artistCredit + ) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record ArtistCredit(@JsonProperty("artist") Artist artist) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Artist(String id, String name) {} + } + } +} diff --git a/src/main/java/com/discdrop/db/DatabaseService.java b/src/main/java/com/discdrop/db/DatabaseService.java new file mode 100644 index 0000000..bad87dc --- /dev/null +++ b/src/main/java/com/discdrop/db/DatabaseService.java @@ -0,0 +1,357 @@ +package com.discdrop.db; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.sqlite.SQLiteDataSource; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +public class DatabaseService { + + private SQLiteDataSource ds; + + @ConfigProperty(name = "discdrop.db.path") + String dbPath; + + @PostConstruct + void init() throws IOException { + Path dbFile = Path.of(dbPath); + Path parent = dbFile.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + ds = new SQLiteDataSource(); + ds.setUrl("jdbc:sqlite:" + dbPath); + ds.setEnforceForeignKeys(true); + + try (Connection conn = ds.getConnection(); Statement stmt = conn.createStatement()) { + stmt.execute(""" + CREATE TABLE IF NOT EXISTS tracked_artist ( + mbid TEXT PRIMARY KEY, + name TEXT NOT NULL, + disambiguation TEXT NOT NULL DEFAULT '', + area_name TEXT NOT NULL DEFAULT '', + album_monitored INTEGER NOT NULL DEFAULT 1, + single_monitored INTEGER NOT NULL DEFAULT 0, + ep_monitored INTEGER NOT NULL DEFAULT 0, + broadcast_monitored INTEGER NOT NULL DEFAULT 0, + other_monitored INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """); + stmt.execute(""" + CREATE TABLE IF NOT EXISTS release_group_cache ( + mbid TEXT PRIMARY KEY, + title TEXT NOT NULL, + primary_type TEXT NOT NULL, + secondary_types TEXT NOT NULL DEFAULT '[]', + first_release_date TEXT, + artist_mbid TEXT NOT NULL, + artist_name TEXT NOT NULL, + cached_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """); + stmt.execute("CREATE INDEX IF NOT EXISTS idx_rgc_artist ON release_group_cache(artist_mbid)"); + stmt.execute("CREATE INDEX IF NOT EXISTS idx_rgc_date ON release_group_cache(first_release_date)"); + stmt.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """); + stmt.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('cache_ttl_hours', '8')"); + stmt.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('default_types', 'Album')"); + } catch (SQLException e) { + throw new RuntimeException("Failed to initialize database", e); + } + } + + Connection getConnection() throws SQLException { + return ds.getConnection(); + } + + // --- tracked_artist DAO --- + + public List findAllArtists() { + var artists = new ArrayList(); + var sql = "SELECT * FROM tracked_artist ORDER BY name"; + try (Connection conn = getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + artists.add(mapArtist(rs)); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return artists; + } + + public Optional findByMbid(String mbid) { + var sql = "SELECT * FROM tracked_artist WHERE mbid = ?"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, mbid); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return Optional.of(mapArtist(rs)); + } + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return Optional.empty(); + } + + public boolean exists(String mbid) { + var sql = "SELECT 1 FROM tracked_artist WHERE mbid = ?"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, mbid); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public void insertArtist(Record.TrackedArtist a) { + var sql = """ + INSERT INTO tracked_artist + (mbid, name, disambiguation, area_name, + album_monitored, single_monitored, ep_monitored, + broadcast_monitored, other_monitored) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, a.mbid()); + ps.setString(2, a.name()); + ps.setString(3, a.disambiguation()); + ps.setString(4, a.areaName()); + ps.setInt(5, a.albumMonitored() ? 1 : 0); + ps.setInt(6, a.singleMonitored() ? 1 : 0); + ps.setInt(7, a.epMonitored() ? 1 : 0); + ps.setInt(8, a.broadcastMonitored() ? 1 : 0); + ps.setInt(9, a.otherMonitored() ? 1 : 0); + ps.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public void updateMonitoring(String mbid, Record.MonitoringFlags flags) { + var sql = """ + UPDATE tracked_artist SET + album_monitored = ?, single_monitored = ?, ep_monitored = ?, + broadcast_monitored = ?, other_monitored = ?, + updated_at = datetime('now') + WHERE mbid = ? + """; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, flags.album() ? 1 : 0); + ps.setInt(2, flags.single() ? 1 : 0); + ps.setInt(3, flags.ep() ? 1 : 0); + ps.setInt(4, flags.broadcast() ? 1 : 0); + ps.setInt(5, flags.other() ? 1 : 0); + ps.setString(6, mbid); + ps.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public void deleteArtist(String mbid) { + var sql = "DELETE FROM tracked_artist WHERE mbid = ?"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, mbid); + ps.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + // --- release_group_cache DAO --- + + public void upsertReleaseGroups(List groups) { + var sql = """ + INSERT OR REPLACE INTO release_group_cache + (mbid, title, primary_type, secondary_types, + first_release_date, artist_mbid, artist_name, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + """; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (var g : groups) { + ps.setString(1, g.mbid()); + ps.setString(2, g.title()); + ps.setString(3, g.primaryType()); + ps.setString(4, g.secondaryTypes()); + ps.setString(5, g.firstReleaseDate()); + ps.setString(6, g.artistMbid()); + ps.setString(7, g.artistName()); + ps.addBatch(); + } + ps.executeBatch(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public record FeedResult(List entries, boolean hasMore) {} + + public FeedResult getFeed(List artistMbids, List primaryTypes, int limit, int offset) { + var groups = new ArrayList(); + if (artistMbids.isEmpty()) return new FeedResult(groups, false); + + var placeholders = String.join(",", artistMbids.stream().map(m -> "?").toList()); + var sql = "SELECT * FROM release_group_cache WHERE artist_mbid IN (" + placeholders + ")"; + if (!primaryTypes.isEmpty()) { + var typePlaceholders = String.join(",", primaryTypes.stream().map(t -> "?").toList()); + sql += " AND primary_type IN (" + typePlaceholders + ")"; + } + sql += " ORDER BY (CASE WHEN first_release_date IS NULL OR first_release_date = '' THEN 1 ELSE 0 END), first_release_date DESC"; + + boolean useLimit = limit > 0; + if (useLimit) sql += " LIMIT ? OFFSET ?"; + + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + int idx = 1; + for (var mbid : artistMbids) ps.setString(idx++, mbid); + for (var type : primaryTypes) ps.setString(idx++, type); + if (useLimit) { + ps.setInt(idx++, limit + 1); + ps.setInt(idx, offset); + } + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + groups.add(mapReleaseGroup(rs)); + } + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + boolean hasMore = useLimit && groups.size() > limit; + if (hasMore) groups.remove(groups.size() - 1); + return new FeedResult(groups, hasMore); + } + + public void deleteReleaseGroupsForArtist(String mbid) { + var sql = "DELETE FROM release_group_cache WHERE artist_mbid = ?"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, mbid); + ps.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public long countReleaseGroupsForArtist(String mbid) { + var sql = "SELECT COUNT(*) FROM release_group_cache WHERE artist_mbid = ?"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, mbid); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return rs.getLong(1); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return 0; + } + + public Optional getLastCachedAt(String artistMbid) { + var sql = "SELECT MAX(cached_at) FROM release_group_cache WHERE artist_mbid = ?"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, artistMbid); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next() && rs.getString(1) != null) { + return Optional.of(Instant.parse(rs.getString(1).replace(" ", "T") + "Z")); + } + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return Optional.empty(); + } + + // --- settings DAO --- + + public String getSetting(String key, String defaultValue) { + var sql = "SELECT value FROM settings WHERE key = ?"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, key); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return rs.getString("value"); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return defaultValue; + } + + public void setSetting(String key, String value) { + var sql = "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)"; + try (Connection conn = getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, key); + ps.setString(2, value); + ps.executeUpdate(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + // --- mappers --- + + private Record.TrackedArtist mapArtist(ResultSet rs) throws SQLException { + return new Record.TrackedArtist( + rs.getString("mbid"), + rs.getString("name"), + rs.getString("disambiguation"), + rs.getString("area_name"), + rs.getInt("album_monitored") == 1, + rs.getInt("single_monitored") == 1, + rs.getInt("ep_monitored") == 1, + rs.getInt("broadcast_monitored") == 1, + rs.getInt("other_monitored") == 1, + parseInstant(rs.getString("created_at")), + parseInstant(rs.getString("updated_at")) + ); + } + + private Record.ReleaseGroup mapReleaseGroup(ResultSet rs) throws SQLException { + return new Record.ReleaseGroup( + rs.getString("mbid"), + rs.getString("title"), + rs.getString("primary_type"), + rs.getString("secondary_types"), + rs.getString("first_release_date"), + rs.getString("artist_mbid"), + rs.getString("artist_name"), + parseInstant(rs.getString("cached_at")) + ); + } + + private Instant parseInstant(String ts) { + if (ts == null || ts.isBlank()) return null; + return Instant.parse(ts.replace(" ", "T") + "Z"); + } +} diff --git a/src/main/java/com/discdrop/db/Record.java b/src/main/java/com/discdrop/db/Record.java new file mode 100644 index 0000000..fe12a09 --- /dev/null +++ b/src/main/java/com/discdrop/db/Record.java @@ -0,0 +1,41 @@ +package com.discdrop.db; + +import java.time.Instant; + +public final class Record { + + private Record() {} + + public record TrackedArtist( + String mbid, + String name, + String disambiguation, + String areaName, + boolean albumMonitored, + boolean singleMonitored, + boolean epMonitored, + boolean broadcastMonitored, + boolean otherMonitored, + Instant createdAt, + Instant updatedAt + ) {} + + public record MonitoringFlags( + boolean album, + boolean single, + boolean ep, + boolean broadcast, + boolean other + ) {} + + public record ReleaseGroup( + String mbid, + String title, + String primaryType, + String secondaryTypes, + String firstReleaseDate, + String artistMbid, + String artistName, + Instant cachedAt + ) {} +} diff --git a/src/main/java/com/discdrop/service/ArtistService.java b/src/main/java/com/discdrop/service/ArtistService.java new file mode 100644 index 0000000..fde16b6 --- /dev/null +++ b/src/main/java/com/discdrop/service/ArtistService.java @@ -0,0 +1,113 @@ +package com.discdrop.service; + +import com.discdrop.client.MusicBrainzClient; +import com.discdrop.client.dto.ArtistDetailsResponse; +import com.discdrop.client.dto.ArtistSearchResponse; +import com.discdrop.db.DatabaseService; +import com.discdrop.db.Record.MonitoringFlags; +import com.discdrop.db.Record.TrackedArtist; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +public class ArtistService { + + @Inject + @RestClient + MusicBrainzClient mbClient; + + @Inject + DatabaseService db; + + public record SearchResult(String mbid, String name, String disambiguation, + String area, String type, String country, + boolean alreadyTracked) {} + + public List search(String query) { + var resp = mbClient.searchArtists( + "artist:" + query, 10, "json"); + if (resp == null || resp.artists() == null) return List.of(); + + var results = new ArrayList(); + for (var a : resp.artists()) { + results.add(new SearchResult( + a.id(), + a.name(), + a.disambiguation() != null ? a.disambiguation() : "", + a.area() != null ? a.area().name() : (a.country() != null ? a.country() : ""), + a.type() != null ? a.type() : "", + a.country() != null ? a.country() : "", + db.exists(a.id()) + )); + } + return results; + } + + public Optional findByMbid(String mbid) { + return db.findByMbid(mbid); + } + + public List findAll() { + return db.findAllArtists(); + } + + public TrackedArtist addArtist(String mbid) { + ArtistDetailsResponse details = mbClient.getArtistDetails(mbid, "url-rels", "json"); + if (details == null) { + throw new RuntimeException("Artist not found: " + mbid); + } + + var defaultTypes = db.getSetting("default_types", "Album"); + var flags = parseDefaultTypes(defaultTypes); + + var artist = new TrackedArtist( + details.id(), + details.name(), + details.disambiguation() != null ? details.disambiguation() : "", + details.area() != null ? details.area().name() : "", + flags.album(), + flags.single(), + flags.ep(), + flags.broadcast(), + flags.other(), + Instant.now(), + Instant.now() + ); + + db.insertArtist(artist); + return artist; + } + + public void removeArtist(String mbid) { + db.deleteReleaseGroupsForArtist(mbid); + db.deleteArtist(mbid); + } + + public void updateMonitoring(String mbid, MonitoringFlags flags) { + db.updateMonitoring(mbid, flags); + } + + private MonitoringFlags parseDefaultTypes(String types) { + boolean album = false, single = false, ep = false, + broadcast = false, other = false; + if (types == null || types.isBlank()) { + album = true; + return new MonitoringFlags(album, single, ep, broadcast, other); + } + for (var t : types.split(",")) { + switch (t.trim().toLowerCase()) { + case "album" -> album = true; + case "single" -> single = true; + case "ep" -> ep = true; + case "broadcast" -> broadcast = true; + case "other" -> other = true; + } + } + return new MonitoringFlags(album, single, ep, broadcast, other); + } +} diff --git a/src/main/java/com/discdrop/service/CacheService.java b/src/main/java/com/discdrop/service/CacheService.java new file mode 100644 index 0000000..57aec51 --- /dev/null +++ b/src/main/java/com/discdrop/service/CacheService.java @@ -0,0 +1,36 @@ +package com.discdrop.service; + +import com.discdrop.db.DatabaseService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +@ApplicationScoped +public class CacheService { + + @Inject + DatabaseService db; + + public long getTtlHours() { + var ttl = db.getSetting("cache_ttl_hours", "8"); + try { + return Long.parseLong(ttl); + } catch (NumberFormatException e) { + return 8; + } + } + + public boolean isStale(String artistMbid) { + Optional lastCached = db.getLastCachedAt(artistMbid); + if (lastCached.isEmpty()) return true; + + long ttlHours = getTtlHours(); + return lastCached.get().plus(ttlHours, ChronoUnit.HOURS).isBefore(Instant.now()); + } + + public boolean needsRefresh(String artistMbid) { + return db.countReleaseGroupsForArtist(artistMbid) == 0 || isStale(artistMbid); + } +} diff --git a/src/main/java/com/discdrop/service/FeedService.java b/src/main/java/com/discdrop/service/FeedService.java new file mode 100644 index 0000000..ef22d9e --- /dev/null +++ b/src/main/java/com/discdrop/service/FeedService.java @@ -0,0 +1,92 @@ +package com.discdrop.service; + +import com.discdrop.db.DatabaseService; +import com.discdrop.db.DatabaseService.FeedResult; +import com.discdrop.db.Record.ReleaseGroup; +import com.discdrop.db.Record.TrackedArtist; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.List; + +@ApplicationScoped +public class FeedService { + + @Inject + ArtistService artistService; + + @Inject + ReleaseGroupService releaseGroupService; + + @Inject + CacheService cacheService; + + @Inject + DatabaseService db; + + public static final int PAGE_SIZE = 10; + + public List getAllFeedEntries() { + var artists = artistService.findAll(); + if (artists.isEmpty()) return List.of(); + + var artistMbids = artistMbidsWithRefresh(artists); + var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other"); + var allEntries = db.getFeed(artistMbids, allTypes, 0, 0).entries(); + return filterByArtistMonitoring(allEntries, artists); + } + + public FeedResult getFeedEntries(int page) { + var artists = artistService.findAll(); + if (artists.isEmpty()) return new FeedResult(List.of(), false); + + var artistMbids = artistMbidsWithRefresh(artists); + var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other"); + var allEntries = db.getFeed(artistMbids, allTypes, 0, 0).entries(); + var filtered = filterByArtistMonitoring(allEntries, artists); + + var fromIndex = (page - 1) * PAGE_SIZE; + if (fromIndex >= filtered.size()) return new FeedResult(List.of(), false); + var toIndex = Math.min(fromIndex + PAGE_SIZE, filtered.size()); + var pageEntries = filtered.subList(fromIndex, toIndex); + var hasMore = toIndex < filtered.size(); + return new FeedResult(pageEntries, hasMore); + } + + private List artistMbidsWithRefresh(List artists) { + var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other"); + var mbids = new ArrayList(); + for (var a : artists) { + if (cacheService.needsRefresh(a.mbid())) { + releaseGroupService.fetchAndCache(a.mbid(), a.name(), allTypes); + } + mbids.add(a.mbid()); + } + return mbids; + } + + private List filterByArtistMonitoring(List entries, List artists) { + var flags = new java.util.HashMap>(); + for (var a : artists) { + flags.put(a.mbid(), java.util.Set.copyOf(getMonitoredTypes(a))); + } + var result = new ArrayList(); + for (var e : entries) { + var artistFlags = flags.get(e.artistMbid()); + if (artistFlags != null && artistFlags.contains(e.primaryType())) { + result.add(e); + } + } + return result; + } + + private List getMonitoredTypes(TrackedArtist a) { + var types = new ArrayList(); + if (a.albumMonitored()) types.add("Album"); + if (a.singleMonitored()) types.add("Single"); + if (a.epMonitored()) types.add("EP"); + if (a.broadcastMonitored()) types.add("Broadcast"); + if (a.otherMonitored()) types.add("Other"); + return types; + } +} diff --git a/src/main/java/com/discdrop/service/ReleaseGroupService.java b/src/main/java/com/discdrop/service/ReleaseGroupService.java new file mode 100644 index 0000000..3a7a126 --- /dev/null +++ b/src/main/java/com/discdrop/service/ReleaseGroupService.java @@ -0,0 +1,90 @@ +package com.discdrop.service; + +import com.discdrop.client.MusicBrainzClient; +import com.discdrop.client.dto.ReleaseGroupBrowseResponse; +import com.discdrop.db.DatabaseService; +import com.discdrop.db.Record.ReleaseGroup; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@ApplicationScoped +public class ReleaseGroupService { + + private static final int MAX_RELEASE_GROUPS = 500; + private static final int PAGE_SIZE = 100; + + @Inject + @RestClient + MusicBrainzClient mbClient; + + @Inject + DatabaseService db; + + @Inject + ObjectMapper json; + + public List fetchAndCache(String artistMbid, String artistName, + List monitoredTypes) { + var allGroups = new ArrayList(); + int offset = 0; + int total = Integer.MAX_VALUE; + + while (offset < total && offset < MAX_RELEASE_GROUPS) { + ReleaseGroupBrowseResponse page = mbClient.browseReleaseGroups( + artistMbid, PAGE_SIZE, offset, "url-rels+artist-credits", "json"); + + if (page == null || page.releaseGroups() == null) break; + + total = Math.min(page.releaseGroupCount(), MAX_RELEASE_GROUPS); + + for (var rg : page.releaseGroups()) { + String primaryType = rg.primaryType() != null ? rg.primaryType() : "Other"; + + String secondaryTypes = "[]"; + if (rg.secondaryTypes() != null && !rg.secondaryTypes().isEmpty()) { + try { + secondaryTypes = json.writeValueAsString(rg.secondaryTypes()); + } catch (Exception e) { + secondaryTypes = "[]"; + } + } + + String rgArtistName = artistName; + if (rg.artistCredit() != null && !rg.artistCredit().isEmpty()) { + rgArtistName = rg.artistCredit().get(0).artist().name(); + } + + allGroups.add(new ReleaseGroup( + rg.id(), + rg.title(), + primaryType, + secondaryTypes, + rg.firstReleaseDate(), + artistMbid, + rgArtistName, + Instant.now() + )); + } + + offset += PAGE_SIZE; + + if (offset < total) { + try { Thread.sleep(1100); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + + if (!allGroups.isEmpty()) { + db.upsertReleaseGroups(allGroups); + } + + return allGroups; + } +} diff --git a/src/main/java/com/discdrop/util/RSSFeedBuilder.java b/src/main/java/com/discdrop/util/RSSFeedBuilder.java new file mode 100644 index 0000000..9f0a40e --- /dev/null +++ b/src/main/java/com/discdrop/util/RSSFeedBuilder.java @@ -0,0 +1,123 @@ +package com.discdrop.util; + +import com.discdrop.db.Record.ReleaseGroup; +import com.rometools.rome.feed.synd.*; +import com.rometools.rome.io.SyndFeedOutput; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +@ApplicationScoped +public class RSSFeedBuilder { + + @ConfigProperty(name = "discdrop.base-url") + String baseUrl; + + public String buildRss(List entries) { + var feed = new SyndFeedImpl(); + feed.setFeedType("rss_2.0"); + feed.setTitle("DiscDrop — Release Feed"); + feed.setLink(baseUrl); + feed.setDescription("Latest music release groups from your tracked artists"); + + var selfLink = new SyndLinkImpl(); + selfLink.setHref(baseUrl + "/feed/rss.xml"); + selfLink.setRel("self"); + selfLink.setType("application/rss+xml"); + feed.setLinks(List.of(selfLink)); + + var items = new ArrayList(); + for (var rg : entries) { + var item = new SyndEntryImpl(); + item.setTitle(rg.artistName() + " — " + rg.title()); + item.setLink("https://musicbrainz.org/release-group/" + rg.mbid()); + + item.setUri("mbz-rg-" + rg.mbid()); + + var desc = new SyndContentImpl(); + desc.setType("text/html"); + desc.setValue(buildDescription(rg)); + item.setDescription(desc); + + item.setPublishedDate(parseDate(rg.firstReleaseDate())); + + var category = new SyndCategoryImpl(); + category.setName(rg.primaryType()); + item.setCategories(List.of(category)); + + items.add(item); + } + feed.setEntries(items); + feed.setPublishedDate(new Date()); + + try { + var output = new SyndFeedOutput(); + return output.outputString(feed); + } catch (Exception e) { + throw new RuntimeException("Failed to generate RSS feed", e); + } + } + + public String buildOpml() { + return """ + + + + DiscDrop — Release Feed + + + + + + """.formatted(baseUrl, baseUrl); + } + + private String buildDescription(ReleaseGroup rg) { + var sb = new StringBuilder(); + sb.append("\"Cover\""); + sb.append("

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

"); + return sb.toString(); + } + + private Date parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) return new Date(); + try { + ZonedDateTime zdt; + if (dateStr.length() == 4) { + zdt = ZonedDateTime.of( + Integer.parseInt(dateStr), 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); + } else if (dateStr.length() == 7) { + var parts = dateStr.split("-"); + zdt = ZonedDateTime.of( + Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), + 1, 0, 0, 0, 0, ZoneId.of("UTC")); + } else { + zdt = ZonedDateTime.parse(dateStr + "T00:00:00Z"); + } + return Date.from(zdt.toInstant()); + } catch (Exception e) { + return new Date(); + } + } + + private String escHtml(String s) { + if (s == null) return ""; + return s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/src/main/java/com/discdrop/web/ArtistController.java b/src/main/java/com/discdrop/web/ArtistController.java new file mode 100644 index 0000000..71b9ef5 --- /dev/null +++ b/src/main/java/com/discdrop/web/ArtistController.java @@ -0,0 +1,150 @@ +package com.discdrop.web; + +import com.discdrop.db.DatabaseService.FeedResult; +import com.discdrop.db.Record.MonitoringFlags; +import com.discdrop.db.Record.TrackedArtist; +import com.discdrop.service.ArtistService; +import com.discdrop.service.ArtistService.SearchResult; +import com.discdrop.service.FeedService; +import com.discdrop.service.ReleaseGroupService; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.List; + +@Path("/api/artists") +public class ArtistController { + + @Inject + ArtistService artistService; + + @Inject + ReleaseGroupService releaseGroupService; + + @Inject + FeedService feedService; + + @Inject + @Location("fragments/search-results") + Template searchResultsTemplate; + + @Inject + @Location("fragments/artist-list") + Template artistListTemplate; + + @Inject + @Location("fragments/feed-entries") + Template feedEntriesTemplate; + + @GET + @Path("/search") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance search(@QueryParam("q") String q) { + List results; + if (q == null || q.isBlank()) { + results = List.of(); + } else { + results = artistService.search(q); + } + return searchResultsTemplate.data("artists", results); + } + + @POST + @Path("/{mbid}/track") + @Produces(MediaType.TEXT_HTML) + public Response trackArtist(@PathParam("mbid") String mbid) { + var artist = artistService.addArtist(mbid); + releaseGroupService.fetchAndCache(artist.mbid(), artist.name(), List.of("Album", "Single", "EP", "Broadcast", "Other")); + var allArtists = artistService.findAll(); + var listHtml = artistListTemplate.data("artists", allArtists).render(); + return Response.ok(listHtml + feedReloadScript()).build(); + } + + @DELETE + @Path("/{mbid}") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance removeArtist(@PathParam("mbid") String mbid) { + artistService.removeArtist(mbid); + var allArtists = artistService.findAll(); + return artistListTemplate.data("artists", allArtists); + } + + @DELETE + @Path("/{mbid}/from-search") + @Produces(MediaType.TEXT_HTML) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response removeArtistFromSearch(@PathParam("mbid") String mbid, + @QueryParam("q") @DefaultValue("") String q, + @FormParam("q") @DefaultValue("") String qForm) { + artistService.removeArtist(mbid); + var query = q != null && !q.isBlank() ? q : qForm; + List results; + if (query.isBlank()) { + results = List.of(); + } else { + results = artistService.search(query); + } + var searchHtml = searchResultsTemplate.data("artists", results).render(); + var allArtists = artistService.findAll(); + var listHtml = artistListTemplate.data("artists", allArtists).render(); + return Response.ok(searchHtml + listHtml + feedReloadScript()).build(); + } + + @PATCH + @Path("/{mbid}/monitor") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance updateMonitoring( + @PathParam("mbid") String mbid, + @QueryParam("type") String type) { + var existing = artistService.findByMbid(mbid) + .orElseThrow(() -> new NotFoundException("Artist not found")); + var flags = new MonitoringFlags( + type.equals("album") ? !existing.albumMonitored() : existing.albumMonitored(), + type.equals("single") ? !existing.singleMonitored() : existing.singleMonitored(), + type.equals("ep") ? !existing.epMonitored() : existing.epMonitored(), + type.equals("broadcast") ? !existing.broadcastMonitored() : existing.broadcastMonitored(), + type.equals("other") ? !existing.otherMonitored() : existing.otherMonitored() + ); + artistService.updateMonitoring(mbid, flags); + var allArtists = artistService.findAll(); + return artistListTemplate.data("artists", allArtists); + } + + @GET + @Path("/list") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance artistList() { + var artists = artistService.findAll(); + return artistListTemplate.data("artists", artists); + } + + @GET + @Path("/feed-table") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance feedTable(@QueryParam("page") @DefaultValue("1") int page) { + var result = feedService.getFeedEntries(page); + return feedEntriesTemplate + .data("entries", result.entries()) + .data("hasMore", result.hasMore()) + .data("nextPage", page + 1); + } + + private List getMonitoredTypes(TrackedArtist a) { + var types = new ArrayList(); + if (a.albumMonitored()) types.add("Album"); + if (a.singleMonitored()) types.add("Single"); + if (a.epMonitored()) types.add("EP"); + if (a.broadcastMonitored()) types.add("Broadcast"); + if (a.otherMonitored()) types.add("Other"); + return types; + } + + private String feedReloadScript() { + return " + + + + + + +
+ {#insert page-content}{/insert} +
+ + + + + + diff --git a/src/main/resources/templates/releases.html b/src/main/resources/templates/releases.html new file mode 100644 index 0000000..5c948b9 --- /dev/null +++ b/src/main/resources/templates/releases.html @@ -0,0 +1,26 @@ +{#include layouts/base} +{#page-content} +
+
+

📡 Releases

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