@@ -0,0 +1,14 @@
|
|||||||
|
# Prevent local build artifacts from leaking into Docker
|
||||||
|
target/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
.gitea/
|
||||||
@@ -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"
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
target/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.gitea/
|
||||||
+24
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.discdrop</groupId>
|
||||||
|
<artifactId>discdrop</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<quarkus.platform.version>3.37.1</quarkus.platform.version>
|
||||||
|
<compiler-plugin.version>3.13.0</compiler-plugin.version>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<surefire-plugin.version>3.5.2</surefire-plugin.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-bom</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- REST server (resteasy-reactive successor) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-resteasy</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Qute templating -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-resteasy-qute</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- REST Client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-resteasy-client</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- CDI -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-arc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- SQLite JDBC -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
|
<version>3.49.1.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- RSS generation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.rometools</groupId>
|
||||||
|
<artifactId>rome</artifactId>
|
||||||
|
<version>2.1.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson for JSON parsing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-resteasy-client-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<version>5.5.1</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
<goal>generate-code</goal>
|
||||||
|
<goal>generate-code-tests</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>${compiler-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<release>21</release>
|
||||||
|
<parameters>true</parameters>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${surefire-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||||
|
<maven.home>${maven.home}</maven.home>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.discdrop.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ArtistDetailsResponse(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String disambiguation,
|
||||||
|
Area area,
|
||||||
|
String type,
|
||||||
|
String country,
|
||||||
|
List<Relation> relations
|
||||||
|
) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Area(String name) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Relation(String type, Url url) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Url(String resource) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.discdrop.client.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ArtistSearchResponse(
|
||||||
|
List<Artist> artists,
|
||||||
|
int count
|
||||||
|
) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Artist(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String disambiguation,
|
||||||
|
Area area,
|
||||||
|
String type,
|
||||||
|
String country
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Area(String name) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<ReleaseGroup> 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<String> secondaryTypes,
|
||||||
|
@JsonProperty("artist-credit") List<ArtistCredit> artistCredit
|
||||||
|
) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record ArtistCredit(@JsonProperty("artist") Artist artist) {
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Artist(String id, String name) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Record.TrackedArtist> findAllArtists() {
|
||||||
|
var artists = new ArrayList<Record.TrackedArtist>();
|
||||||
|
var sql = "SELECT * FROM tracked_artist ORDER BY name";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery(sql)) {
|
||||||
|
while (rs.next()) {
|
||||||
|
artists.add(mapArtist(rs));
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Record.TrackedArtist> findByMbid(String mbid) {
|
||||||
|
var sql = "SELECT * FROM tracked_artist WHERE mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, mbid);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return Optional.of(mapArtist(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean exists(String mbid) {
|
||||||
|
var sql = "SELECT 1 FROM tracked_artist WHERE mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, mbid);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next();
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertArtist(Record.TrackedArtist a) {
|
||||||
|
var sql = """
|
||||||
|
INSERT INTO tracked_artist
|
||||||
|
(mbid, name, disambiguation, area_name,
|
||||||
|
album_monitored, single_monitored, ep_monitored,
|
||||||
|
broadcast_monitored, other_monitored)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, a.mbid());
|
||||||
|
ps.setString(2, a.name());
|
||||||
|
ps.setString(3, a.disambiguation());
|
||||||
|
ps.setString(4, a.areaName());
|
||||||
|
ps.setInt(5, a.albumMonitored() ? 1 : 0);
|
||||||
|
ps.setInt(6, a.singleMonitored() ? 1 : 0);
|
||||||
|
ps.setInt(7, a.epMonitored() ? 1 : 0);
|
||||||
|
ps.setInt(8, a.broadcastMonitored() ? 1 : 0);
|
||||||
|
ps.setInt(9, a.otherMonitored() ? 1 : 0);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateMonitoring(String mbid, Record.MonitoringFlags flags) {
|
||||||
|
var sql = """
|
||||||
|
UPDATE tracked_artist SET
|
||||||
|
album_monitored = ?, single_monitored = ?, ep_monitored = ?,
|
||||||
|
broadcast_monitored = ?, other_monitored = ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE mbid = ?
|
||||||
|
""";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setInt(1, flags.album() ? 1 : 0);
|
||||||
|
ps.setInt(2, flags.single() ? 1 : 0);
|
||||||
|
ps.setInt(3, flags.ep() ? 1 : 0);
|
||||||
|
ps.setInt(4, flags.broadcast() ? 1 : 0);
|
||||||
|
ps.setInt(5, flags.other() ? 1 : 0);
|
||||||
|
ps.setString(6, mbid);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteArtist(String mbid) {
|
||||||
|
var sql = "DELETE FROM tracked_artist WHERE mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, mbid);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- release_group_cache DAO ---
|
||||||
|
|
||||||
|
public void upsertReleaseGroups(List<Record.ReleaseGroup> groups) {
|
||||||
|
var sql = """
|
||||||
|
INSERT OR REPLACE INTO release_group_cache
|
||||||
|
(mbid, title, primary_type, secondary_types,
|
||||||
|
first_release_date, artist_mbid, artist_name, cached_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
""";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
for (var g : groups) {
|
||||||
|
ps.setString(1, g.mbid());
|
||||||
|
ps.setString(2, g.title());
|
||||||
|
ps.setString(3, g.primaryType());
|
||||||
|
ps.setString(4, g.secondaryTypes());
|
||||||
|
ps.setString(5, g.firstReleaseDate());
|
||||||
|
ps.setString(6, g.artistMbid());
|
||||||
|
ps.setString(7, g.artistName());
|
||||||
|
ps.addBatch();
|
||||||
|
}
|
||||||
|
ps.executeBatch();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FeedResult(List<Record.ReleaseGroup> entries, boolean hasMore) {}
|
||||||
|
|
||||||
|
public FeedResult getFeed(List<String> artistMbids, List<String> primaryTypes, int limit, int offset) {
|
||||||
|
var groups = new ArrayList<Record.ReleaseGroup>();
|
||||||
|
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<Instant> getLastCachedAt(String artistMbid) {
|
||||||
|
var sql = "SELECT MAX(cached_at) FROM release_group_cache WHERE artist_mbid = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, artistMbid);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next() && rs.getString(1) != null) {
|
||||||
|
return Optional.of(Instant.parse(rs.getString(1).replace(" ", "T") + "Z"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- settings DAO ---
|
||||||
|
|
||||||
|
public String getSetting(String key, String defaultValue) {
|
||||||
|
var sql = "SELECT value FROM settings WHERE key = ?";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, key);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) return rs.getString("value");
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSetting(String key, String value) {
|
||||||
|
var sql = "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)";
|
||||||
|
try (Connection conn = getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, key);
|
||||||
|
ps.setString(2, value);
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mappers ---
|
||||||
|
|
||||||
|
private Record.TrackedArtist mapArtist(ResultSet rs) throws SQLException {
|
||||||
|
return new Record.TrackedArtist(
|
||||||
|
rs.getString("mbid"),
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("disambiguation"),
|
||||||
|
rs.getString("area_name"),
|
||||||
|
rs.getInt("album_monitored") == 1,
|
||||||
|
rs.getInt("single_monitored") == 1,
|
||||||
|
rs.getInt("ep_monitored") == 1,
|
||||||
|
rs.getInt("broadcast_monitored") == 1,
|
||||||
|
rs.getInt("other_monitored") == 1,
|
||||||
|
parseInstant(rs.getString("created_at")),
|
||||||
|
parseInstant(rs.getString("updated_at"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Record.ReleaseGroup mapReleaseGroup(ResultSet rs) throws SQLException {
|
||||||
|
return new Record.ReleaseGroup(
|
||||||
|
rs.getString("mbid"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("primary_type"),
|
||||||
|
rs.getString("secondary_types"),
|
||||||
|
rs.getString("first_release_date"),
|
||||||
|
rs.getString("artist_mbid"),
|
||||||
|
rs.getString("artist_name"),
|
||||||
|
parseInstant(rs.getString("cached_at"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant parseInstant(String ts) {
|
||||||
|
if (ts == null || ts.isBlank()) return null;
|
||||||
|
return Instant.parse(ts.replace(" ", "T") + "Z");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.discdrop.db;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public final class Record {
|
||||||
|
|
||||||
|
private Record() {}
|
||||||
|
|
||||||
|
public record TrackedArtist(
|
||||||
|
String mbid,
|
||||||
|
String name,
|
||||||
|
String disambiguation,
|
||||||
|
String areaName,
|
||||||
|
boolean albumMonitored,
|
||||||
|
boolean singleMonitored,
|
||||||
|
boolean epMonitored,
|
||||||
|
boolean broadcastMonitored,
|
||||||
|
boolean otherMonitored,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record MonitoringFlags(
|
||||||
|
boolean album,
|
||||||
|
boolean single,
|
||||||
|
boolean ep,
|
||||||
|
boolean broadcast,
|
||||||
|
boolean other
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record ReleaseGroup(
|
||||||
|
String mbid,
|
||||||
|
String title,
|
||||||
|
String primaryType,
|
||||||
|
String secondaryTypes,
|
||||||
|
String firstReleaseDate,
|
||||||
|
String artistMbid,
|
||||||
|
String artistName,
|
||||||
|
Instant cachedAt
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<SearchResult> search(String query) {
|
||||||
|
var resp = mbClient.searchArtists(
|
||||||
|
"artist:" + query, 10, "json");
|
||||||
|
if (resp == null || resp.artists() == null) return List.of();
|
||||||
|
|
||||||
|
var results = new ArrayList<SearchResult>();
|
||||||
|
for (var a : resp.artists()) {
|
||||||
|
results.add(new SearchResult(
|
||||||
|
a.id(),
|
||||||
|
a.name(),
|
||||||
|
a.disambiguation() != null ? a.disambiguation() : "",
|
||||||
|
a.area() != null ? a.area().name() : (a.country() != null ? a.country() : ""),
|
||||||
|
a.type() != null ? a.type() : "",
|
||||||
|
a.country() != null ? a.country() : "",
|
||||||
|
db.exists(a.id())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<TrackedArtist> findByMbid(String mbid) {
|
||||||
|
return db.findByMbid(mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TrackedArtist> findAll() {
|
||||||
|
return db.findAllArtists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackedArtist addArtist(String mbid) {
|
||||||
|
ArtistDetailsResponse details = mbClient.getArtistDetails(mbid, "url-rels", "json");
|
||||||
|
if (details == null) {
|
||||||
|
throw new RuntimeException("Artist not found: " + mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultTypes = db.getSetting("default_types", "Album");
|
||||||
|
var flags = parseDefaultTypes(defaultTypes);
|
||||||
|
|
||||||
|
var artist = new TrackedArtist(
|
||||||
|
details.id(),
|
||||||
|
details.name(),
|
||||||
|
details.disambiguation() != null ? details.disambiguation() : "",
|
||||||
|
details.area() != null ? details.area().name() : "",
|
||||||
|
flags.album(),
|
||||||
|
flags.single(),
|
||||||
|
flags.ep(),
|
||||||
|
flags.broadcast(),
|
||||||
|
flags.other(),
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
db.insertArtist(artist);
|
||||||
|
return artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeArtist(String mbid) {
|
||||||
|
db.deleteReleaseGroupsForArtist(mbid);
|
||||||
|
db.deleteArtist(mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateMonitoring(String mbid, MonitoringFlags flags) {
|
||||||
|
db.updateMonitoring(mbid, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MonitoringFlags parseDefaultTypes(String types) {
|
||||||
|
boolean album = false, single = false, ep = false,
|
||||||
|
broadcast = false, other = false;
|
||||||
|
if (types == null || types.isBlank()) {
|
||||||
|
album = true;
|
||||||
|
return new MonitoringFlags(album, single, ep, broadcast, other);
|
||||||
|
}
|
||||||
|
for (var t : types.split(",")) {
|
||||||
|
switch (t.trim().toLowerCase()) {
|
||||||
|
case "album" -> album = true;
|
||||||
|
case "single" -> single = true;
|
||||||
|
case "ep" -> ep = true;
|
||||||
|
case "broadcast" -> broadcast = true;
|
||||||
|
case "other" -> other = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new MonitoringFlags(album, single, ep, broadcast, other);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.discdrop.service;
|
||||||
|
|
||||||
|
import com.discdrop.db.DatabaseService;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class CacheService {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DatabaseService db;
|
||||||
|
|
||||||
|
public long getTtlHours() {
|
||||||
|
var ttl = db.getSetting("cache_ttl_hours", "8");
|
||||||
|
try {
|
||||||
|
return Long.parseLong(ttl);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isStale(String artistMbid) {
|
||||||
|
Optional<Instant> lastCached = db.getLastCachedAt(artistMbid);
|
||||||
|
if (lastCached.isEmpty()) return true;
|
||||||
|
|
||||||
|
long ttlHours = getTtlHours();
|
||||||
|
return lastCached.get().plus(ttlHours, ChronoUnit.HOURS).isBefore(Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean needsRefresh(String artistMbid) {
|
||||||
|
return db.countReleaseGroupsForArtist(artistMbid) == 0 || isStale(artistMbid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<ReleaseGroup> 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<String> artistMbidsWithRefresh(List<TrackedArtist> artists) {
|
||||||
|
var allTypes = List.of("Album", "Single", "EP", "Broadcast", "Other");
|
||||||
|
var mbids = new ArrayList<String>();
|
||||||
|
for (var a : artists) {
|
||||||
|
if (cacheService.needsRefresh(a.mbid())) {
|
||||||
|
releaseGroupService.fetchAndCache(a.mbid(), a.name(), allTypes);
|
||||||
|
}
|
||||||
|
mbids.add(a.mbid());
|
||||||
|
}
|
||||||
|
return mbids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ReleaseGroup> filterByArtistMonitoring(List<ReleaseGroup> entries, List<TrackedArtist> artists) {
|
||||||
|
var flags = new java.util.HashMap<String, java.util.Set<String>>();
|
||||||
|
for (var a : artists) {
|
||||||
|
flags.put(a.mbid(), java.util.Set.copyOf(getMonitoredTypes(a)));
|
||||||
|
}
|
||||||
|
var result = new ArrayList<ReleaseGroup>();
|
||||||
|
for (var e : entries) {
|
||||||
|
var artistFlags = flags.get(e.artistMbid());
|
||||||
|
if (artistFlags != null && artistFlags.contains(e.primaryType())) {
|
||||||
|
result.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> getMonitoredTypes(TrackedArtist a) {
|
||||||
|
var types = new ArrayList<String>();
|
||||||
|
if (a.albumMonitored()) types.add("Album");
|
||||||
|
if (a.singleMonitored()) types.add("Single");
|
||||||
|
if (a.epMonitored()) types.add("EP");
|
||||||
|
if (a.broadcastMonitored()) types.add("Broadcast");
|
||||||
|
if (a.otherMonitored()) types.add("Other");
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<ReleaseGroup> fetchAndCache(String artistMbid, String artistName,
|
||||||
|
List<String> monitoredTypes) {
|
||||||
|
var allGroups = new ArrayList<ReleaseGroup>();
|
||||||
|
int offset = 0;
|
||||||
|
int total = Integer.MAX_VALUE;
|
||||||
|
|
||||||
|
while (offset < total && offset < MAX_RELEASE_GROUPS) {
|
||||||
|
ReleaseGroupBrowseResponse page = mbClient.browseReleaseGroups(
|
||||||
|
artistMbid, PAGE_SIZE, offset, "url-rels+artist-credits", "json");
|
||||||
|
|
||||||
|
if (page == null || page.releaseGroups() == null) break;
|
||||||
|
|
||||||
|
total = Math.min(page.releaseGroupCount(), MAX_RELEASE_GROUPS);
|
||||||
|
|
||||||
|
for (var rg : page.releaseGroups()) {
|
||||||
|
String primaryType = rg.primaryType() != null ? rg.primaryType() : "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReleaseGroup> entries) {
|
||||||
|
var feed = new SyndFeedImpl();
|
||||||
|
feed.setFeedType("rss_2.0");
|
||||||
|
feed.setTitle("DiscDrop — Release Feed");
|
||||||
|
feed.setLink(baseUrl);
|
||||||
|
feed.setDescription("Latest music release groups from your tracked artists");
|
||||||
|
|
||||||
|
var selfLink = new SyndLinkImpl();
|
||||||
|
selfLink.setHref(baseUrl + "/feed/rss.xml");
|
||||||
|
selfLink.setRel("self");
|
||||||
|
selfLink.setType("application/rss+xml");
|
||||||
|
feed.setLinks(List.of(selfLink));
|
||||||
|
|
||||||
|
var items = new ArrayList<SyndEntry>();
|
||||||
|
for (var rg : entries) {
|
||||||
|
var item = new SyndEntryImpl();
|
||||||
|
item.setTitle(rg.artistName() + " — " + rg.title());
|
||||||
|
item.setLink("https://musicbrainz.org/release-group/" + rg.mbid());
|
||||||
|
|
||||||
|
item.setUri("mbz-rg-" + rg.mbid());
|
||||||
|
|
||||||
|
var desc = new SyndContentImpl();
|
||||||
|
desc.setType("text/html");
|
||||||
|
desc.setValue(buildDescription(rg));
|
||||||
|
item.setDescription(desc);
|
||||||
|
|
||||||
|
item.setPublishedDate(parseDate(rg.firstReleaseDate()));
|
||||||
|
|
||||||
|
var category = new SyndCategoryImpl();
|
||||||
|
category.setName(rg.primaryType());
|
||||||
|
item.setCategories(List.of(category));
|
||||||
|
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
feed.setEntries(items);
|
||||||
|
feed.setPublishedDate(new Date());
|
||||||
|
|
||||||
|
try {
|
||||||
|
var output = new SyndFeedOutput();
|
||||||
|
return output.outputString(feed);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to generate RSS feed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildOpml() {
|
||||||
|
return """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<opml version="2.0">
|
||||||
|
<head>
|
||||||
|
<title>DiscDrop — Release Feed</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<outline text="DiscDrop Releases" type="rss"
|
||||||
|
xmlUrl="%s/feed/rss.xml" htmlUrl="%s"/>
|
||||||
|
</body>
|
||||||
|
</opml>
|
||||||
|
""".formatted(baseUrl, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDescription(ReleaseGroup rg) {
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.append("<img src=\"https://coverartarchive.org/release-group/")
|
||||||
|
.append(rg.mbid()).append("/front\" alt=\"Cover\">");
|
||||||
|
sb.append("<p><b>").append(escHtml(rg.artistName())).append("</b>")
|
||||||
|
.append(" <i>").append(escHtml(rg.title())).append("</i><br>");
|
||||||
|
sb.append("Type: ").append(escHtml(rg.primaryType()));
|
||||||
|
if (rg.secondaryTypes() != null && !rg.secondaryTypes().equals("[]")) {
|
||||||
|
sb.append(" | Secondary: ").append(escHtml(rg.secondaryTypes()));
|
||||||
|
}
|
||||||
|
sb.append("<br>First Release Date: ").append(rg.firstReleaseDate());
|
||||||
|
sb.append("</p>");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date parseDate(String dateStr) {
|
||||||
|
if (dateStr == null || dateStr.isBlank()) return new Date();
|
||||||
|
try {
|
||||||
|
ZonedDateTime zdt;
|
||||||
|
if (dateStr.length() == 4) {
|
||||||
|
zdt = ZonedDateTime.of(
|
||||||
|
Integer.parseInt(dateStr), 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"));
|
||||||
|
} else if (dateStr.length() == 7) {
|
||||||
|
var parts = dateStr.split("-");
|
||||||
|
zdt = ZonedDateTime.of(
|
||||||
|
Integer.parseInt(parts[0]), Integer.parseInt(parts[1]),
|
||||||
|
1, 0, 0, 0, 0, ZoneId.of("UTC"));
|
||||||
|
} else {
|
||||||
|
zdt = ZonedDateTime.parse(dateStr + "T00:00:00Z");
|
||||||
|
}
|
||||||
|
return Date.from(zdt.toInstant());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escHtml(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<SearchResult> 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<SearchResult> 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<String> getMonitoredTypes(TrackedArtist a) {
|
||||||
|
var types = new ArrayList<String>();
|
||||||
|
if (a.albumMonitored()) types.add("Album");
|
||||||
|
if (a.singleMonitored()) types.add("Single");
|
||||||
|
if (a.epMonitored()) types.add("EP");
|
||||||
|
if (a.broadcastMonitored()) types.add("Broadcast");
|
||||||
|
if (a.otherMonitored()) types.add("Other");
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String feedReloadScript() {
|
||||||
|
return "<script>setTimeout(function(){var e=document.getElementById('feed-entries');if(e)htmx.ajax('GET','/api/artists/feed-table',{target:'#feed-entries',swap:'innerHTML'})},200)</" + "script>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import io.quarkus.qute.Template;
|
||||||
|
import io.quarkus.qute.TemplateInstance;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
@Path("/artists")
|
||||||
|
public class ArtistsPage {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Template artists;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance index() {
|
||||||
|
return artists.data("activeTab", "artists");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import com.discdrop.service.FeedService;
|
||||||
|
import com.discdrop.util.RSSFeedBuilder;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
@Path("/feed")
|
||||||
|
public class FeedController {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
FeedService feedService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
RSSFeedBuilder feedBuilder;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/rss.xml")
|
||||||
|
@Produces(MediaType.APPLICATION_XML)
|
||||||
|
public Response rssFeed() {
|
||||||
|
var entries = feedService.getAllFeedEntries();
|
||||||
|
var xml = feedBuilder.buildRss(entries);
|
||||||
|
return Response.ok(xml, MediaType.APPLICATION_XML).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/opml")
|
||||||
|
@Produces(MediaType.APPLICATION_XML)
|
||||||
|
public Response opml() {
|
||||||
|
var xml = feedBuilder.buildOpml();
|
||||||
|
return Response.ok(xml, MediaType.APPLICATION_XML).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import io.quarkus.qute.Template;
|
||||||
|
import io.quarkus.qute.TemplateInstance;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
@Path("/")
|
||||||
|
public class ReleasesPage {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Template releases;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance index() {
|
||||||
|
return releases.data("activeTab", "releases");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.discdrop.web;
|
||||||
|
|
||||||
|
import com.discdrop.db.DatabaseService;
|
||||||
|
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.List;
|
||||||
|
|
||||||
|
@Path("/api/settings")
|
||||||
|
public class SettingsController {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DatabaseService db;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@Location("fragments/settings-form")
|
||||||
|
Template settingsFormTemplate;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/form")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public TemplateInstance settingsForm() {
|
||||||
|
var cacheTtl = db.getSetting("cache_ttl_hours", "8");
|
||||||
|
var defaultTypes = db.getSetting("default_types", "Album");
|
||||||
|
return settingsFormTemplate.data("cacheTtl", cacheTtl)
|
||||||
|
.data("defaultTypes", defaultTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PATCH
|
||||||
|
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public Response saveSettings(
|
||||||
|
@FormParam("cache_ttl_hours") @DefaultValue("8") String cacheTtl,
|
||||||
|
@FormParam("default_types") List<String> defaultTypes) {
|
||||||
|
db.setSetting("cache_ttl_hours", cacheTtl);
|
||||||
|
db.setSetting("default_types", defaultTypes != null ? String.join(",", defaultTypes) : "Album");
|
||||||
|
return Response.ok("<div class=\"alert alert-success\">Settings saved</div>")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Server
|
||||||
|
quarkus.http.port=8080
|
||||||
|
|
||||||
|
# MusicBrainz API
|
||||||
|
quarkus.rest-client."com.discdrop.client.MusicBrainzClient".url=https://musicbrainz.org/ws/2
|
||||||
|
|
||||||
|
# Qute templating
|
||||||
|
quarkus.qute.suffixes=html
|
||||||
|
quarkus.qute.content-types.html=text/html
|
||||||
|
|
||||||
|
# Jackson
|
||||||
|
quarkus.jackson.fail-on-unknown-properties=false
|
||||||
|
|
||||||
|
# DiscDrop settings
|
||||||
|
discdrop.db.path=${DISC_DROP_DB_PATH:data/discdrop.db}
|
||||||
|
discdrop.cache.ttl-hours=${DISC_DROP_CACHE_TTL:8}
|
||||||
|
discdrop.base-url=${DISC_DROP_BASE_URL:http://localhost:8080}
|
||||||
|
|
||||||
|
# User-Agent sent to MusicBrainz API (env: DISC_DROP_USER_AGENT)
|
||||||
|
discdrop.user-agent=${DISC_DROP_USER_AGENT:DiscDrop/0.1 (https://https://github.com/droideparanoico/discdrop)}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{#include layouts/base}
|
||||||
|
{#page-content}
|
||||||
|
<div class="w-full">
|
||||||
|
<h2 class="text-2xl font-bold mb-5">🎤 Tracked Artists</h2>
|
||||||
|
<div id="artist-list" hx-get="/api/artists/list" hx-trigger="load">
|
||||||
|
<span class="loading loading-spinner loading-lg block mx-auto my-12"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/page-content}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<div id="artist-list" hx-swap-oob="true">
|
||||||
|
{#if artists.isEmpty()}
|
||||||
|
<div class="bg-base-200" style="border:1px solid var(--fallback-b3,oklch(var(--b3)/1));border-radius:0.5rem;padding:2.5rem;text-align:center">
|
||||||
|
<svg class="icon-lg text-base-content/20" style="margin:0 auto 0.75rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium text-base-content/60">No artists tracked yet</p>
|
||||||
|
<p class="text-sm text-base-content/40 mt-2">Search for artists using the search bar above</p>
|
||||||
|
</div>
|
||||||
|
{#else}
|
||||||
|
<div class="overflow-x-auto rounded-box border border-base-300">
|
||||||
|
<table class="table table-pin-rows">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:var(--fallback-b2,oklch(var(--b2)/1))">
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Since</th>
|
||||||
|
<th>Release Types</th>
|
||||||
|
<th style="width:6rem">Remove</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#for artist in artists}
|
||||||
|
<tr style="transition:background 0.15s" onmouseover="this.style.background='var(--fallback-b2,oklch(var(--b2)/1))'" onmouseout="this.style.background=''">
|
||||||
|
<td>
|
||||||
|
<a href="https://musicbrainz.org/artist/{artist.mbid}" target="_blank" rel="noopener noreferrer" style="font-weight:500;text-decoration:none;color:inherit">{artist.name}</a>
|
||||||
|
{#if artist.disambiguation}
|
||||||
|
<div class="text-xs text-base-content/50">{artist.disambiguation}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/50">{artist.createdAt}</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.375rem">
|
||||||
|
<label class="label cursor-pointer gap-1 p-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary"
|
||||||
|
{#if artist.albumMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=album"
|
||||||
|
hx-target="#artist-list"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs" style="color:var(--p)">Album</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-1 p-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-secondary"
|
||||||
|
{#if artist.singleMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=single"
|
||||||
|
hx-target="#artist-list"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs" style="color:var(--s)">Single</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-1 p-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-accent"
|
||||||
|
{#if artist.epMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=ep"
|
||||||
|
hx-target="#artist-list"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs" style="color:var(--a)">EP</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-1 p-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs"
|
||||||
|
{#if artist.broadcastMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=broadcast"
|
||||||
|
hx-target="#artist-list"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs text-base-content/60">Broadcast</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-1 p-1">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs"
|
||||||
|
{#if artist.otherMonitored}checked{/if}
|
||||||
|
hx-patch="/api/artists/{artist.mbid}/monitor?type=other"
|
||||||
|
hx-target="#artist-list"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<span class="label-text text-xs text-base-content/60">Other</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-square" style="color:var(--er)"
|
||||||
|
hx-delete="/api/artists/{artist.mbid}"
|
||||||
|
hx-target="#artist-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="return confirm('Remove {artist.name}?')">
|
||||||
|
<svg class="icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/for}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{#if entries.isEmpty()}
|
||||||
|
<div class="card bg-base-200 shadow-xl text-center" style="padding:3rem">
|
||||||
|
<p class="text-lg text-base-content/60">No releases found</p>
|
||||||
|
<p class="text-sm text-base-content/40 mt-2">Add artists to your collection to see their releases here</p>
|
||||||
|
</div>
|
||||||
|
{#else}
|
||||||
|
{#for entry in entries}
|
||||||
|
<a href="https://musicbrainz.org/release-group/{entry.mbid}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="card card-side bg-base-200 border border-base-300 hover:bg-base-300 card-shadow overflow-hidden">
|
||||||
|
<figure class="w-64">
|
||||||
|
<img src="https://coverartarchive.org/release-group/{entry.mbid}/front-250"
|
||||||
|
alt="{entry.title}" class="object-cover w-full h-full"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.parentElement.innerHTML='<div class=\'flex items-center justify-center h-full text-base-content/20\'>No Cover</div>'">
|
||||||
|
</figure>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 style="font-size:1.5rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--fallback-bc,oklch(var(--bc)/0.6))">{entry.artistName}</h3>
|
||||||
|
<h2 style="font-size:2rem;font-weight:700">{entry.title}</h2>
|
||||||
|
<div style="margin-top:0.75rem">
|
||||||
|
{#if entry.firstReleaseDate}
|
||||||
|
<div style="font-size:1.5rem;color:var(--fallback-bc,oklch(var(--bc)/0.4))">{entry.firstReleaseDate}</div>
|
||||||
|
{/if}
|
||||||
|
<div style="margin-top:0.4rem">
|
||||||
|
{#if entry.primaryType == 'Album'}
|
||||||
|
<span class="badge badge-primary" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">Album</span>
|
||||||
|
{#else if entry.primaryType == 'Single'}
|
||||||
|
<span class="badge badge-secondary" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">Single</span>
|
||||||
|
{#else if entry.primaryType == 'EP'}
|
||||||
|
<span class="badge badge-accent" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">EP</span>
|
||||||
|
{#else}
|
||||||
|
<span class="badge badge-ghost" style="font-size:1.5rem;padding:0.3rem 0.8rem;height:auto">{entry.primaryType}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/for}
|
||||||
|
{#if hasMore}
|
||||||
|
<div style="display:flex;justify-content:center;padding:1rem 0">
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
hx-get="/api/artists/feed-table?page={nextPage}"
|
||||||
|
hx-target="closest div"
|
||||||
|
hx-swap="outerHTML">Load more</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<div class="card bg-base-300 dropdown-shadow border border-base-300 mt-1" style="max-height:18rem;overflow-y:auto">
|
||||||
|
{#if artists.isEmpty()}
|
||||||
|
<div style="padding:1rem;text-align:center;font-size:0.875rem;color:var(--fallback-bc,oklch(var(--bc)/0.5))">No artists found</div>
|
||||||
|
{#else}
|
||||||
|
{#for artist in artists}
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem;border-bottom:1px solid var(--fallback-b3,oklch(var(--b3)/1));transition:background 0.1s"
|
||||||
|
onmouseover="this.style.background='var(--fallback-b2,oklch(var(--b2)/1))'" onmouseout="this.style.background=''">
|
||||||
|
<div style="flex:1;min-width:0;margin-right:0.5rem">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem">
|
||||||
|
<span style="font-weight:600;font-size:0.9rem">{artist.name}</span>
|
||||||
|
{#if artist.type}
|
||||||
|
<span class="badge badge-ghost badge-xs">{artist.type}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if artist.disambiguation}
|
||||||
|
<div style="font-size:0.75rem;color:var(--fallback-bc,oklch(var(--bc)/0.5));overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{artist.disambiguation}</div>
|
||||||
|
{/if}
|
||||||
|
{#if artist.area}
|
||||||
|
<div style="font-size:0.75rem;color:var(--fallback-bc,oklch(var(--bc)/0.4))">{artist.area}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{#if artist.alreadyTracked}
|
||||||
|
<button class="btn btn-warning btn-sm"
|
||||||
|
hx-delete="/api/artists/{artist.mbid}/from-search"
|
||||||
|
hx-include="#search-input"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-swap="innerHTML">✓ Added</button>
|
||||||
|
{#else}
|
||||||
|
<button class="btn btn-primary btn-sm"
|
||||||
|
hx-post="/api/artists/{artist.mbid}/track"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
onclick="document.getElementById('search-input').value=''">+ Add</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/for}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<h3 class="font-bold text-lg mb-4">⚙️ Settings</h3>
|
||||||
|
<form hx-patch="/api/settings"
|
||||||
|
hx-target="#settings-modal-content"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Refresh releases every</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="radio" name="cache_ttl_hours" value="6" class="radio radio-primary radio-sm"
|
||||||
|
{#if cacheTtl == '6'}checked{/if}>
|
||||||
|
<span class="label-text">6 hours</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="radio" name="cache_ttl_hours" value="8" class="radio radio-primary radio-sm"
|
||||||
|
{#if cacheTtl == '8'}checked{/if}>
|
||||||
|
<span class="label-text">8 hours</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="radio" name="cache_ttl_hours" value="12" class="radio radio-primary radio-sm"
|
||||||
|
{#if cacheTtl == '12'}checked{/if}>
|
||||||
|
<span class="label-text">12 hours</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="radio" name="cache_ttl_hours" value="24" class="radio radio-primary radio-sm"
|
||||||
|
{#if cacheTtl == '24'}checked{/if}>
|
||||||
|
<span class="label-text">24 hours</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Default Release Types (when adding artists)</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="Album" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('Album')}checked{/if}>
|
||||||
|
<span class="label-text">Album</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="Single" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('Single')}checked{/if}>
|
||||||
|
<span class="label-text">Single</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="EP" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('EP')}checked{/if}>
|
||||||
|
<span class="label-text">EP</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="Broadcast" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('Broadcast')}checked{/if}>
|
||||||
|
<span class="label-text">Broadcast</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<input type="checkbox" name="default_types" value="Other" class="checkbox checkbox-sm"
|
||||||
|
{#if defaultTypes.contains('Other')}checked{/if}>
|
||||||
|
<span class="label-text">Other</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn" onclick="settingsModal.close()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DiscDrop</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2/dist/htmx.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
#search-wrapper { position: relative; width: 100%; }
|
||||||
|
#search-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; }
|
||||||
|
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem; }
|
||||||
|
.icon-sm { width: 16px; height: 16px; flex-shrink: 0; }
|
||||||
|
.icon-xs { width: 12px; height: 12px; flex-shrink: 0; }
|
||||||
|
.icon-lg { width: 48px; height: 48px; flex-shrink: 0; }
|
||||||
|
.card-shadow { box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
||||||
|
.card-shadow:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.25); }
|
||||||
|
.dropdown-shadow { box-shadow: 0 8px 24px rgba(0,0,0,0.3); }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var saved = localStorage.getItem('daisyui-theme');
|
||||||
|
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var search = document.getElementById('search-wrapper');
|
||||||
|
var results = document.getElementById('search-results');
|
||||||
|
if (results && search && !search.contains(e.target)) {
|
||||||
|
results.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function setTheme(sel) {
|
||||||
|
var theme = sel.value;
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('daisyui-theme', theme);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-base-100">
|
||||||
|
<div class="navbar bg-base-300 shadow-lg px-4 min-h-0 h-14">
|
||||||
|
<div class="navbar-start"></div>
|
||||||
|
|
||||||
|
<div class="navbar-center gap-4">
|
||||||
|
<a href="/" class="btn btn-ghost btn-sm no-underline">DiscDrop</a>
|
||||||
|
<a href="/artists" class="btn btn-ghost btn-sm {#if activeTab == 'artists'}btn-active{/if}">Artists</a>
|
||||||
|
<div id="search-wrapper" style="width:24rem">
|
||||||
|
<form hx-get="/api/artists/search"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="keyup changed delay:300ms from:#search-input">
|
||||||
|
<input id="search-input" type="text" placeholder="Search artist to follow"
|
||||||
|
class="input input-bordered input-sm" name="q" autocomplete="off" style="width:100%">
|
||||||
|
</form>
|
||||||
|
<div id="search-results"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-end gap-2">
|
||||||
|
<select class="select select-bordered select-sm" style="width:7rem" onchange="setTheme(this)">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="synthwave">Synthwave</option>
|
||||||
|
<option value="retro">Retro</option>
|
||||||
|
<option value="dracula">Dracula</option>
|
||||||
|
<option value="night">Night</option>
|
||||||
|
<option value="dim">Dim</option>
|
||||||
|
<option value="nord">Nord</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="settingsModal.showModal()">⚙️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#insert page-content}{/insert}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="settingsModal" class="modal">
|
||||||
|
<div class="modal-box max-w-md" id="settings-modal-content"
|
||||||
|
hx-get="/api/settings/form" hx-trigger="load" hx-swap="innerHTML">
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{#include layouts/base}
|
||||||
|
{#page-content}
|
||||||
|
<div class="w-full">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;flex-wrap:wrap;gap:0.75rem">
|
||||||
|
<h2 style="font-size:1.5rem;font-weight:700">📡 Releases</h2>
|
||||||
|
<div style="display:flex;gap:0.5rem">
|
||||||
|
<a href="/feed/rss.xml" class="btn btn-primary btn-sm gap-2">
|
||||||
|
<svg class="icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/>
|
||||||
|
<circle cx="5" cy="19" r="1"/>
|
||||||
|
</svg>
|
||||||
|
RSS Feed
|
||||||
|
</a>
|
||||||
|
<a href="/feed/opml" class="btn btn-ghost btn-sm gap-2">
|
||||||
|
<svg class="icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
OPML
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="feed-entries" hx-get="/api/artists/feed-table" hx-trigger="load" style="display:flex;flex-direction:column;gap:2.5rem">
|
||||||
|
<div style="display:flex;justify-content:center;padding:4rem 0">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/page-content}
|
||||||
Reference in New Issue
Block a user