Compare commits
10
Commits
292de62198
...
0e24f380b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e24f380b1 | ||
|
|
ddbced0513 | ||
|
|
6b6af3afa4 | ||
|
|
39eb5dc055 | ||
|
|
62833ed6e8 | ||
|
|
c186fead83 | ||
|
|
105ff5df8b | ||
|
|
c8f888826d | ||
|
|
202a6749f2 | ||
|
|
2078a56fc2 |
@@ -0,0 +1,7 @@
|
|||||||
|
data/
|
||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# DiscDrop — Quarkus multi-stage build
|
||||||
|
|
||||||
|
# ── Stage 1: Build ──────────────────────────────────────
|
||||||
|
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY pom.xml ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn package -DskipTests
|
||||||
|
|
||||||
|
# ── Stage 2: Runtime ────────────────────────────────────
|
||||||
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /build/target/quarkus-app/ /app/
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 8080
|
||||||
|
USER discdrop
|
||||||
|
ENTRYPOINT ["java", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=20", "-jar", "quarkus-run.jar"]
|
||||||
@@ -26,4 +26,4 @@ The H2 file database lives in `./data/discdrop.mv.db` (gitignored).
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
See `../discdrop-plan.md` for the full design.
|
See [discdrop-plan.md](discdrop-plan.md) for the full design.
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
# DiscDrop — Implementation Plan
|
||||||
|
|
||||||
|
A self-hosted, single-user app to follow MusicBrainz artists and track their **release groups** (not individual releases, to avoid duplicate vinyl/CD editions). Shows a combined web feed plus an RSS feed of new release groups, ordered by first-release-date.
|
||||||
|
|
||||||
|
> Tagline: _Drop the needle on every new release from the artists you follow._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview & Goals
|
||||||
|
|
||||||
|
| Aspect | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Language / framework | Java 17 + Quarkus 3.x |
|
||||||
|
| Frontend | Server-rendered Qute templates + **htmx** + **daisyUI** (Tailwind) |
|
||||||
|
| Persistence | **Embedded H2 (file-based)** via Quarkus Hibernate ORM + Panache |
|
||||||
|
| MBZ access | **Quarkus REST Client (MicroProfile) + JSON** (no external Java library) |
|
||||||
|
| RSS | Single global RSS 2.0 feed of all followed artists |
|
||||||
|
| Users | Single-user, local/self-hosted (no auth layer in v1) |
|
||||||
|
| MBZ identity | `User-Agent: DiscDrop/1.0 ([email protected])` |
|
||||||
|
| Build / run | Maven (`./mvnw quarkus:dev`) |
|
||||||
|
|
||||||
|
### Functional requirements covered
|
||||||
|
1. Modern, adaptive UI with daisyUI theme switching.
|
||||||
|
2. Logo + favicon reflecting the app's purpose (vinyl "drop").
|
||||||
|
3. One combined feed of all followed artists.
|
||||||
|
4. Artist search autocomplete showing **disambiguation** + **area name**.
|
||||||
|
5. Centered, responsive header: logo, app name, search box, settings, theme switcher.
|
||||||
|
6. Feed rows: artist name, release title, release type, release date, MBZ link; ordered by first-release-date; "load more" pagination.
|
||||||
|
7. Follow artists from search results.
|
||||||
|
8. Unfollow already-tracked artists (search shows "unfollow" when tracked).
|
||||||
|
9. Follow/unfollow updates the feed in place (htmx) and clears the search box.
|
||||||
|
10. Click-outside closes the autocomplete dropdown.
|
||||||
|
11. Track **release groups**, not releases.
|
||||||
|
12. Separate **Artists page**: unfollow + per-artist primary-type monitoring toggles.
|
||||||
|
13. **Settings**: default primary type for new artists (default `album`) + sync schedule (6/12/24 h); closes on click-outside.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────── Browser (htmx + daisyUI) ────────────────┐
|
||||||
|
│ Header: logo · DiscDrop · search · settings · theme │
|
||||||
|
│ Feed (Qute fragments swapped by htmx) │
|
||||||
|
│ /artists page · /rss feed │
|
||||||
|
└───────────────────────────┬──────────────────────────────┘
|
||||||
|
│ HTTP (HTML fragments / RSS / JSON)
|
||||||
|
┌─────────────── Quarkus App (DiscDrop) ───────────────────┐
|
||||||
|
│ Resources (Qute controllers) │
|
||||||
|
│ ├─ PageResource GET / (index + feed fragment) │
|
||||||
|
│ ├─ SearchResource GET /search/artists?q= (fragment) │
|
||||||
|
│ ├─ ArtistResource POST/DELETE follow · GET /artists │
|
||||||
|
│ ├─ SettingsResource GET/POST /settings (fragment) │
|
||||||
|
│ └─ RssResource GET /rss │
|
||||||
|
│ Services │
|
||||||
|
│ ├─ FeedService builds ordered feed page │
|
||||||
|
│ ├─ ArtistService follow/unfollow, settings │
|
||||||
|
│ ├─ SyncService refresh release groups │
|
||||||
|
│ └─ SettingsService app-wide settings │
|
||||||
|
│ MusicBrainzClient (REST Client, JSON, rate-limited) │
|
||||||
|
│ Panache repositories (FollowedArtist, ReleaseGroup, …) │
|
||||||
|
└───────────────┬───────────────────────┬──────────────────┘
|
||||||
|
H2 file DB MusicBrainz API
|
||||||
|
(discdrop.mv.db) https://musicbrainz.org/ws/2/
|
||||||
|
(1 req/sec, User-Agent)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request flow examples**
|
||||||
|
- Search typing → `GET /search/artists?q=…` → `MusicBrainzClient.searchArtists()` → Qute fragment (dropdown rows).
|
||||||
|
- Follow click → `POST /artists/follow` → persist `FollowedArtist` (+ default type settings) → enqueue immediate sync for that artist → return **updated feed fragment** + empty search fragment.
|
||||||
|
- Scheduled sync → for each followed artist, page through `release-group?artist=…&type=…` → upsert `ReleaseGroup` rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Model
|
||||||
|
|
||||||
|
Panache entities backed by H2. Keep it small; no premature normalization.
|
||||||
|
|
||||||
|
### `FollowedArtist`
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | Long (auto) | PK |
|
||||||
|
| mbid | String (UUID) | unique; MBZ artist MBID |
|
||||||
|
| name | String | display name |
|
||||||
|
| sortName | String | |
|
||||||
|
| disambiguation | String | e.g. "90s US grunge band" |
|
||||||
|
| areaName | String | from `area.name` |
|
||||||
|
| type | String | Person / Group / … |
|
||||||
|
| country | String | |
|
||||||
|
| followedAt | Instant | |
|
||||||
|
| lastSyncedAt | Instant | nullable |
|
||||||
|
|
||||||
|
### `ArtistTypeSetting`
|
||||||
|
Per-artist toggle for each **primary type** (requirement 12).
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | Long | PK |
|
||||||
|
| artist | FollowedArtist | FK |
|
||||||
|
| primaryType | String | `album` / `single` / `ep` / `broadcast` / `other` |
|
||||||
|
| enabled | boolean | |
|
||||||
|
|
||||||
|
Primary types come from the MBZ spec: `album, single, ep, broadcast, other`.
|
||||||
|
On follow, seed one row per primary type with `enabled` derived from the **default primary types** setting (`album` enabled by default).
|
||||||
|
|
||||||
|
### `ReleaseGroup`
|
||||||
|
Cached release groups (one row per MBZ release group per artist).
|
||||||
|
| field | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | Long | PK |
|
||||||
|
| mbid | String (UUID) | unique |
|
||||||
|
| artist | FollowedArtist | FK |
|
||||||
|
| title | String | |
|
||||||
|
| firstReleaseDate | LocalDate | sortable; may be null/partial |
|
||||||
|
| firstReleaseDateRaw | String | preserve "2024-03" / "2024" granularity |
|
||||||
|
| primaryType | String | album / single / ep / … |
|
||||||
|
| secondaryTypes | String | comma-joined (live, compilation, …) |
|
||||||
|
| mbzUrl | String | `https://musicbrainz.org/release-group/{mbid}` |
|
||||||
|
| discoveredAt | Instant | when first seen by sync |
|
||||||
|
|
||||||
|
Unique on `mbid`. Index on `(firstReleaseDate DESC)` for feed ordering.
|
||||||
|
|
||||||
|
> **Cover art** needs no stored field — the front-cover URL is deterministic from the MBID: `https://coverartarchive.org/release-group/{mbid}/front` (Cover Art Archive, an MBZ-affiliated service). See §4.5.
|
||||||
|
|
||||||
|
### `AppSetting`
|
||||||
|
Key/value for global settings (requirement 13).
|
||||||
|
| key | example value | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `defaultPrimaryTypes` | `album` | comma-list applied to new artists |
|
||||||
|
| `syncScheduleHours` | `6` | one of `6`, `12`, `24` |
|
||||||
|
| `theme` | `dark` | optional server-side mirror of client theme |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. MusicBrainz Integration
|
||||||
|
|
||||||
|
### 4.1 Endpoints used (all `fmt=json`)
|
||||||
|
|
||||||
|
| Purpose | Method + URL |
|
||||||
|
|---|---|
|
||||||
|
| Artist search (autocomplete) | `GET /ws/2/artist?query={q}&limit=10&fmt=json` |
|
||||||
|
| Browse release groups by artist | `GET /ws/2/release-group?artist={mbid}&type={types}&limit=100&offset={n}&inc=artist-credits&fmt=json&release-group-status=website-default` |
|
||||||
|
| (Optional) Artist lookup on follow | `GET /ws/2/artist/{mbid}?inc=aliases&fmt=json` |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `type` accepts primary types joined by `|` (e.g. `album|ep`); maps directly to each artist's enabled `ArtistTypeSetting` rows.
|
||||||
|
- `release-group-status=website-default` excludes groups that contain only promotional/bootleg/pseudo-release editions — keeps the feed clean. Default on; can later expose `all`.
|
||||||
|
- Release-group JSON fields we use: `id`, `title`, `first-release-date`, `primary-type`, `secondary-types[]`, `artist-credit[]` (for display name fallback).
|
||||||
|
- Artist search JSON fields we use: `id`, `name`, `disambiguation`, `area.name`, `type`, `country`, `score`.
|
||||||
|
|
||||||
|
### 4.2 REST Client definition (sketch)
|
||||||
|
|
||||||
|
The `User-Agent` is **not** hardcoded in the interface — it is supplied at runtime by a `ClientHeadersFactory` that reads `discdrop.mbz.user-agent` from `application.properties` (see §10). This keeps MBZ contact info out of source so it can be overridden per environment (dev vs. prod, different maintainer email) without recompiling.
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ApplicationScoped
|
||||||
|
@RegisterRestClient(configKey = "musicbrainz")
|
||||||
|
@RegisterClientHeaders(MusicBrainzHeadersFactory.class)
|
||||||
|
public interface MusicBrainzClient {
|
||||||
|
@GET @Path("/artist")
|
||||||
|
ArtistSearchResult searchArtists(@QueryParam("query") String q,
|
||||||
|
@QueryParam("limit") int limit,
|
||||||
|
@QueryParam("fmt") String fmt);
|
||||||
|
|
||||||
|
@GET @Path("/release-group")
|
||||||
|
ReleaseGroupBrowseResult browseReleaseGroups(@QueryParam("artist") String mbid,
|
||||||
|
@QueryParam("type") String types,
|
||||||
|
@QueryParam("limit") int limit,
|
||||||
|
@QueryParam("offset") int offset,
|
||||||
|
@QueryParam("inc") String inc,
|
||||||
|
@QueryParam("fmt") String fmt,
|
||||||
|
@QueryParam("release-group-status") String status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ApplicationScoped
|
||||||
|
public class MusicBrainzHeadersFactory implements ClientHeadersFactory {
|
||||||
|
@ConfigProperty(name = "discdrop.mbz.user-agent")
|
||||||
|
String userAgent;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MultivaluedMap<String, String> update(MultivaluedMap<String, String> incoming,
|
||||||
|
MultivaluedMap<String, String> outgoing) {
|
||||||
|
outgoing.putSingle("User-Agent", userAgent);
|
||||||
|
return outgoing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Base URI and other REST Client settings are externalized via `quarkus.rest-client."musicbrainz".*` keys in `application.properties` (see §10).
|
||||||
|
|
||||||
|
### 4.3 Rate limiting (mandatory: ≤ 1 req/sec)
|
||||||
|
|
||||||
|
A single shared gate that **all** MBZ calls pass through:
|
||||||
|
- `MusicBrainzService` wraps the client with a synchronized `acquire()` that enforces ≥1000 ms between outgoing requests (track `lastCallMillis`).
|
||||||
|
- The background sync loop naturally spaces calls (one artist/page at a time).
|
||||||
|
- Live search also goes through `acquire()`; combined with 300 ms client-side debounce, this keeps us compliant even with fast typing.
|
||||||
|
|
||||||
|
### 4.4 DTOs (Jackson)
|
||||||
|
`ArtistSearchResult { count, artists[] }` → `ArtistDto { id, name, disambiguation, area { name }, type, country, score }`.
|
||||||
|
`ReleaseGroupBrowseResult { "release-group-count", "release-group-offset", "release-groups[] }` → `ReleaseGroupDto { id, title, first-release-date, primary-type, secondary-types[], artist-credit[] }`.
|
||||||
|
|
||||||
|
### 4.5 Cover Art Archive
|
||||||
|
Cover art comes from the **Cover Art Archive** (`https://coverartarchive.org`), the MBZ-affiliated image service hosted by the Internet Archive. No API key required.
|
||||||
|
- **Front cover by release-group MBID**: `https://coverartarchive.org/release-group/{mbid}/front` — responds with `307` redirect to the archived image (JPEG), or `404` if no art exists.
|
||||||
|
- This URL is **deterministic from the MBID**, so:
|
||||||
|
- No extra HTTP calls during sync.
|
||||||
|
- No `coverArtUrl` field on `ReleaseGroup` needed.
|
||||||
|
- Templates render `<img src="https://coverartarchive.org/release-group/{mbid}/front" onerror="this.src='/img/no-cover.svg'">` — the browser follows the redirect, and a small JS `onerror` swaps to a local placeholder when a release group has no art (common for bootlegs/obscure releases).
|
||||||
|
- **Optional optimization (later)**: during sync, issue a `HEAD` per release group and store a `hasCoverArt` boolean to avoid any broken-image flashes. Defer this unless the `onerror` flicker is bothersome; it adds N calls per sync and is unnecessary for v1.
|
||||||
|
- Rate limits on `coverartarchive.org` are more lenient than the MBZ web service, and since we hit it client-side (browser `<img>`) or not at all, it never contends with our 1 req/sec MBZ gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sync / Scheduler
|
||||||
|
|
||||||
|
- `SyncService` with a `@Scheduled` method. The cadence is read from `AppSetting.syncScheduleHours` and reconfigured at runtime (Quarkus supports programmatic scheduler via `Scheduler` / `ScheduledExecutor`, or simply restart after settings change for v1).
|
||||||
|
- Per run, iterate `FollowedArtist` rows; for each, build the `type=` filter from its enabled `ArtistTypeSetting` rows, page through release groups (offset increments by received count, up to `limit=100`), and upsert into `ReleaseGroup`.
|
||||||
|
- After an artist is processed, update `lastSyncedAt`.
|
||||||
|
- **Immediate sync on follow**: when a user follows an artist, trigger a one-off sync for that artist (async, fire-and-forget) so the feed populates without waiting for the next schedule.
|
||||||
|
- Honor rate limit between every MBZ call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI / UX
|
||||||
|
|
||||||
|
### 6.1 Layout & theming
|
||||||
|
- Single base template (`templates/index.html`) with a sticky **header** and the **feed** below.
|
||||||
|
- daisyUI via CDN for rapid dev (`daisyui` full CSS + Tailwind Play CDN); switch to a built/minified local Tailwind CSS for production.
|
||||||
|
- Theme switching: toggle `data-theme` on `<html>` (e.g. `light`/`dark` or a daisyUI pair like `nord`/`dim`). Persist in `localStorage` for instant restore; optionally mirror to `AppSetting.theme`. Small vanilla JS handler on the theme switcher button.
|
||||||
|
|
||||||
|
### 6.2 Header (centered, responsive, consistent gap)
|
||||||
|
A flex container, `justify-center`, `gap-4`, wrapping gracefully on narrow screens:
|
||||||
|
`[logo] [DiscDrop] [search box] [settings btn] [theme switcher]`
|
||||||
|
|
||||||
|
- **Logo**: small SVG (see §7).
|
||||||
|
- **App name**: "DiscDrop" wordmark.
|
||||||
|
- **Search box**: text input with autocomplete dropdown anchored below it.
|
||||||
|
- **Settings button**: opens a dropdown panel (§6.6).
|
||||||
|
- **Theme switcher**: icon button cycling/toggling themes.
|
||||||
|
|
||||||
|
### 6.3 Search + autocomplete (requirements 4, 7, 8, 9, 10)
|
||||||
|
- Input wired with htmx: `hx-get="/search/artists?q=..."`, `hx-trigger="input changed delay:300ms, keyup changed"`, `hx-target="#search-dropdown"`, `hx-swap="innerHTML"`.
|
||||||
|
- Dropdown rows render: **name** · **disambiguation** (muted) · **area name** (badge). Each row carries a **Follow**/**Unfollow** button depending on whether the MBID is already in `FollowedArtist`.
|
||||||
|
- Follow/Unfollow buttons: `hx-post="/artists/follow"` / `hx-delete="/artists/{mbid}"` with:
|
||||||
|
- `hx-target="#feed-list"` and `hx-swap="innerHTML"` (refresh feed), **and**
|
||||||
|
- `hx-on::after-request="this.closest('form').reset(); document.querySelector('#search-dropdown').innerHTML='';"` (clear input + close dropdown) — satisfying requirement 9.
|
||||||
|
- **Click-outside to close** (requirement 10): a tiny JS helper listens for `document` clicks; if the target is outside `#search-wrap`, it empties `#search-dropdown`. (Same helper pattern reused for settings.)
|
||||||
|
|
||||||
|
### 6.4 Feed (requirements 3, 6, 11)
|
||||||
|
- Server renders the first page of release groups ordered by `firstReleaseDate DESC`.
|
||||||
|
- Each row: **cover art thumbnail** (see §4.5 — `<img src="https://coverartarchive.org/release-group/{mbid}/front" onerror="…no-cover placeholder…">`, sized ~64–96px, lazy-loaded), **artist name** (link to artist page section), **release title**, **release type** badge(s) (primary + secondary), **release date** (human-friendly, with raw granularity preserved), and an **external link** to `https://musicbrainz.org/release-group/{mbid}`.
|
||||||
|
- **Load more**: a button `hx-get="/feed?offset={N+page}" hx-target="this" hx-swap="outerHTML"` that appends the next page and re-emits itself with the new offset, until exhausted (then no button).
|
||||||
|
|
||||||
|
### 6.5 Artists page (requirement 12)
|
||||||
|
- Route `/artists` listing every `FollowedArtist` as a card/row with:
|
||||||
|
- name + disambiguation + area,
|
||||||
|
- **Unfollow** button (`hx-delete="/artists/{mbid}"`, target the row, swap `outerHTML` → removes row),
|
||||||
|
- **primary-type toggles**: a set of daisyUI checkboxes/switches for `album, single, ep, broadcast, other`; each change `hx-post="/artists/{mbid}/types"` (persist + re-sync that artist) with `hx-swap="none"` and an out-of-band feed refresh.
|
||||||
|
|
||||||
|
### 6.6 Settings (requirement 13)
|
||||||
|
- Settings button opens a dropdown panel (anchored, same click-outside helper).
|
||||||
|
- Controls:
|
||||||
|
- **Default primary types** for newly followed artists (checkbox set; default `album` checked) → `AppSetting.defaultPrimaryTypes`.
|
||||||
|
- **Sync schedule**: radio/select among `6h / 12h / 24h` → `AppSetting.syncScheduleHours`.
|
||||||
|
- Save: `hx-post="/settings"` with `hx-swap="innerHTML"` on the panel (re-render + confirm). Click-outside closes (does not save unsaved changes in v1, or autosave on change — pick autosave-on-change to keep it simple).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Branding: Logo & Favicon
|
||||||
|
|
||||||
|
Concept: **a vinyl record dropping into a slot** — reinforces "DiscDrop".
|
||||||
|
|
||||||
|
**Logo SVG (inline, scales in header)**
|
||||||
|
- Outer black circle (vinyl) with 2–3 thin concentric groove rings.
|
||||||
|
- Colored center label (accent theme color) with a tiny spindle hole.
|
||||||
|
- A subtle downward chevron / motion line at the bottom-right indicating "drop".
|
||||||
|
- Wordmark "DiscDrop" next to it in the header.
|
||||||
|
|
||||||
|
**Favicon**
|
||||||
|
- Simplified 32×32 / SVG: just the vinyl circle + center label + a small downward arrow. Provide `favicon.svg` (modern browsers) plus a generated `favicon.ico`/`192.png` for broader support.
|
||||||
|
|
||||||
|
Assets live in `src/main/resources/static/img/` and are referenced from the base template (`<link rel="icon" ...>` and header `<img>`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. htmx Interaction Flows (summary)
|
||||||
|
|
||||||
|
| Action | Request | Targets / effects |
|
||||||
|
|---|---|---|
|
||||||
|
| Type in search | `GET /search/artists?q=` | swap `#search-dropdown` |
|
||||||
|
| Follow | `POST /artists/follow` | swap `#feed-list` + clear search + close dropdown |
|
||||||
|
| Unfollow (from search) | `DELETE /artists/{mbid}` | swap `#feed-list` + clear search + close dropdown |
|
||||||
|
| Unfollow (artists page) | `DELETE /artists/{mbid}` | remove row (`outerHTML`) + OOB feed refresh |
|
||||||
|
| Toggle artist type | `POST /artists/{mbid}/types` | OOB `#feed-list` refresh |
|
||||||
|
| Load more | `GET /feed?offset=` | append rows, replace button |
|
||||||
|
| Save settings | `POST /settings` | re-render panel |
|
||||||
|
| Theme switch | (client JS) | toggle `data-theme`, save `localStorage` |
|
||||||
|
|
||||||
|
Out-of-band swaps (`hx-swap-oob`) keep the feed consistent when follow/unfollow/type-toggle happens from anywhere but the feed itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. RSS Feed
|
||||||
|
|
||||||
|
- `GET /rss` returns **RSS 2.0** (`application/rss+xml`; Qute template generating XML, or a small builder).
|
||||||
|
- Single global feed: latest N (e.g. 50) release groups across all followed artists, ordered by `firstReleaseDate DESC`.
|
||||||
|
- Each `<item>`: title = `{artist} – {title}`; link = `https://musicbrainz.org/release-group/{mbid}`; pubDate = `firstReleaseDate`; description = type(s) + date; `<guid>` = the MBZ MBID (stable).
|
||||||
|
- **Cover art**: each item embeds the front cover via the [Media RSS](https://www.rssboard.org/media-rss) namespace (`xmlns:media="http://search.yahoo.com/mrss/"`):
|
||||||
|
```xml
|
||||||
|
<media:content url="https://coverartarchive.org/release-group/{mbid}/front"
|
||||||
|
type="image/jpeg" medium="image" />
|
||||||
|
```
|
||||||
|
Media RSS is preferred over a plain `<enclosure>` because `<enclosure>` requires a `length` (byte size) attribute we don't know without a HEAD probe, whereas `<media:content>` has no such requirement and is widely supported by modern RSS readers. Add `xmlns:media` to the `<rss>` root element.
|
||||||
|
- Channel metadata: "DiscDrop – New Releases", link to the app root.
|
||||||
|
- Discoverable via `<link rel="alternate" type="application/rss+xml" ...>` in the base template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
discdrop/
|
||||||
|
├── pom.xml
|
||||||
|
├── README.md
|
||||||
|
├── src/main/java/io/discdrop/
|
||||||
|
│ ├── DiscDropApp.java
|
||||||
|
│ ├── resource/
|
||||||
|
│ │ ├── PageResource.java # GET / (index + feed fragment), GET /feed
|
||||||
|
│ │ ├── SearchResource.java # GET /search/artists
|
||||||
|
│ │ ├── ArtistResource.java # follow/unfollow, GET /artists, type toggles
|
||||||
|
│ │ ├── SettingsResource.java # GET/POST /settings
|
||||||
|
│ │ └── RssResource.java # GET /rss
|
||||||
|
│ ├── service/
|
||||||
|
│ │ ├── FeedService.java
|
||||||
|
│ │ ├── ArtistService.java
|
||||||
|
│ │ ├── SyncService.java
|
||||||
|
│ │ └── SettingsService.java
|
||||||
|
│ ├── mbz/
|
||||||
|
│ │ ├── MusicBrainzClient.java # REST Client interface (no hardcoded User-Agent)
|
||||||
|
│ │ ├── MusicBrainzHeadersFactory.java # injects discdrop.mbz.user-agent from config
|
||||||
|
│ │ ├── MusicBrainzService.java # rate-limited wrapper
|
||||||
|
│ │ └── dto/{ArtistSearchResult,ArtistDto,ReleaseGroupBrowseResult,ReleaseGroupDto}.java
|
||||||
|
│ └── persistence/
|
||||||
|
│ ├── FollowedArtist.java
|
||||||
|
│ ├── ArtistTypeSetting.java
|
||||||
|
│ ├── ReleaseGroupEntity.java
|
||||||
|
│ └── AppSetting.java
|
||||||
|
├── src/main/resources/
|
||||||
|
│ ├── application.properties
|
||||||
|
│ ├── templates/
|
||||||
|
│ │ ├── index.html # base: header + feed
|
||||||
|
│ │ ├── artists.html
|
||||||
|
│ │ ├── rss.xml
|
||||||
|
│ │ └── fragments/
|
||||||
|
│ │ ├── feed-list.html
|
||||||
|
│ │ ├── feed-row.html
|
||||||
|
│ │ ├── search-dropdown.html
|
||||||
|
│ │ ├── settings-panel.html
|
||||||
|
│ │ └── artist-row.html
|
||||||
|
│ └── static/
|
||||||
|
│ ├── img/{logo.svg, favicon.svg, favicon.ico, no-cover.svg}
|
||||||
|
│ ├── css/ (daisyUI/Tailwind, dev via CDN or built)
|
||||||
|
│ └── js/app.js (theme switch + click-outside helper)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key `application.properties`
|
||||||
|
```properties
|
||||||
|
quarkus.application.name=DiscDrop
|
||||||
|
quarkus.http.port=8080
|
||||||
|
|
||||||
|
# H2 file DB
|
||||||
|
quarkus.datasource.db-kind=h2
|
||||||
|
quarkus.datasource.jdbc.url=jdbc:h2:file:./data/discdrop;DB_CLOSE_DELAY=-1
|
||||||
|
quarkus.datasource.username=sa
|
||||||
|
quarkus.datasource.password=
|
||||||
|
quarkus.hibernate-orm.database.generation=update
|
||||||
|
|
||||||
|
# MusicBrainz REST client
|
||||||
|
quarkus.rest-client."musicbrainz".url=https://musicbrainz.org/ws/2
|
||||||
|
# User-Agent (MBZ requires a meaningful contact string) — override per environment, never hardcode in code
|
||||||
|
discdrop.mbz.user-agent=DiscDrop/1.0 ([email protected])
|
||||||
|
discdrop.mbz.rate-limit-ms=1000
|
||||||
|
discdrop.feed.page-size=25
|
||||||
|
discdrop.rss.item-count=50
|
||||||
|
```
|
||||||
|
On startup, validate that `discdrop.mbz.user-agent` is non-empty (fail fast with a clear `StartupException` rather than emitting anonymous requests MBZ may block).
|
||||||
|
|
||||||
|
### Maven dependencies (essentials)
|
||||||
|
- `quarkus-resteasy-reactive-qute`, `quarkus-rest-client-jackson`, `quarkus-hibernate-orm`, `quarkus-jdbc-h2`, `quarkus-panache`, `quarkus-scheduler`, `quarkus-resteasy-reactive-jackson`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Implementation Phases
|
||||||
|
|
||||||
|
### Phase 0 — Scaffold (0.5 day)
|
||||||
|
- `quarkus create app` (Java 17, Maven, REST + Qute + Hibernate + H2 + Scheduler + REST Client).
|
||||||
|
- Base template with daisyUI CDN, htmx CDN, logo/favicon placeholders, header layout.
|
||||||
|
- Verify `./mvnw quarkus:dev` boots and renders the header.
|
||||||
|
|
||||||
|
### Phase 1 — MBZ client + search (1 day)
|
||||||
|
- `MusicBrainzClient` + DTOs + rate-limited `MusicBrainzService`.
|
||||||
|
- `/search/artists` returning Qute dropdown fragment (name, disambiguation, area).
|
||||||
|
- Click-outside close helper.
|
||||||
|
|
||||||
|
### Phase 2 — Persistence + follow/unfollow (1 day)
|
||||||
|
- Panache entities + repositories; `SettingsService` defaults.
|
||||||
|
- Follow/unfollow endpoints; seed `ArtistTypeSetting` from default primary types.
|
||||||
|
- Immediate one-off sync on follow; feed fragment endpoint.
|
||||||
|
- Wire follow buttons in search dropdown; clear search + refresh feed via htmx.
|
||||||
|
|
||||||
|
### Phase 3 — Sync + feed (1 day)
|
||||||
|
- `SyncService` scheduler (configurable 6/12/24 h); paged release-group browse + upsert.
|
||||||
|
- Feed ordering by `firstReleaseDate DESC`, row fragment with **cover art thumbnail** (Cover Art Archive URL + `onerror` placeholder), **load more** pagination.
|
||||||
|
- MBZ external links on each row.
|
||||||
|
|
||||||
|
### Phase 4 — Artists page + type toggles (0.5 day)
|
||||||
|
- `/artists` page with unfollow + per-artist primary-type switches; OOB feed refresh on change.
|
||||||
|
|
||||||
|
### Phase 5 — Settings panel (0.5 day)
|
||||||
|
- Settings dropdown: default primary types + sync schedule; autosave-on-change; click-outside close.
|
||||||
|
|
||||||
|
### Phase 6 — RSS + branding polish (0.5 day)
|
||||||
|
- `/rss` RSS 2.0 with `<media:content>` cover art per item; `<link rel="alternate">` discovery.
|
||||||
|
- Finalize logo/favicon SVG + `no-cover.svg` placeholder; theme switcher; responsive pass.
|
||||||
|
|
||||||
|
### Phase 7 — Hardening (0.5 day)
|
||||||
|
- Error states (MBZ down / rate-limited / empty feed) with friendly UI.
|
||||||
|
- README with run + config instructions.
|
||||||
|
- Optional: replace daisyUI/Tailwind CDN with a built local CSS for production.
|
||||||
|
|
||||||
|
**Estimated total: ~5–6 days.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of Scope (v1) / Future
|
||||||
|
- Multi-user / auth (currently single-user local).
|
||||||
|
- Cover-art `HEAD` probing during sync (to pre-flag missing art and avoid broken-image flashes); the `onerror` placeholder handles this client-side for v1.
|
||||||
|
- Secondary-type per-artist filters (only primary types are toggled per requirement 12; secondary types are shown as badges).
|
||||||
|
- Notifications (email/webhook) beyond RSS.
|
||||||
|
- Migration to PostgreSQL (entity model is portable; only datasource config changes).
|
||||||
|
- Replacing the daisyUI/Tailwind CDN with a production Tailwind build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Key Design Decisions & Rationale
|
||||||
|
- **Release groups, not releases** (req 11): browse `release-group` with `inc=artist-credits`; one row per group, no duplicate vinyl/CD.
|
||||||
|
- **`release-group-status=website-default`**: filters promo/bootleg/pseudo-release-only groups → cleaner feed.
|
||||||
|
- **Server-side rate limiting shared by search + sync**: guarantees the 1 req/sec MBZ rule regardless of UI activity.
|
||||||
|
- **Quarkus REST Client + JSON** over the dead/GPL `musicbrainzws2-java` library: lighter, idiomatic, no licensing constraints.
|
||||||
|
- **Externalized `User-Agent` via `ClientHeadersFactory`**: the MBZ contact string lives in `application.properties` (`discdrop.mbz.user-agent`), not in code — so it can be overridden per environment and updated without a recompile. App fails fast at startup if the property is missing, to avoid sending anonymous requests MBZ may block.
|
||||||
|
- **H2 file-based**: zero-ops for a personal app; portable to Postgres later by changing datasource props.
|
||||||
|
- **htmx + Qute fragments**: the feed, search dropdown, and settings all swap HTML fragments — no SPA needed; matches requirements 9 and 10 (in-place updates, dropdown close).
|
||||||
|
- **Cover art via deterministic Cover Art Archive URLs**: `https://coverartarchive.org/release-group/{mbid}/front` needs no per-item API call or storage — the URL is derived from the MBID, fetched lazily by the browser `<img>`, with a JS `onerror` placeholder for release groups without art. Keeps sync fast and the data model lean.
|
||||||
|
- **Media RSS `<media:content>` over `<enclosure>`** for RSS images: avoids the mandatory `length` (byte-size) attribute that `<enclosure>` requires and that we'd need a HEAD probe to obtain.
|
||||||
|
- **Autosave settings on change**: avoids a "save" button and keeps the settings panel simple (closes on click-outside per req 13).
|
||||||
@@ -29,8 +29,10 @@ public class AppSetting extends PanacheEntity {
|
|||||||
if (s == null) {
|
if (s == null) {
|
||||||
s = new AppSetting();
|
s = new AppSetting();
|
||||||
s.key = key;
|
s.key = key;
|
||||||
|
s.value = value;
|
||||||
s.persist();
|
s.persist();
|
||||||
}
|
} else {
|
||||||
s.value = value;
|
s.value = value;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class ReleaseGroupRepository {
|
public class ReleaseGroupRepository {
|
||||||
@@ -29,6 +32,25 @@ public class ReleaseGroupRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void replaceForArtist(String mbid, List<ReleaseGroupBrowseResult.ReleaseGroupDto> newDtos) {
|
||||||
|
FollowedArtist artist = FollowedArtist.findByMbid(mbid);
|
||||||
|
if (artist == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<String> newMbids = new HashSet<>();
|
||||||
|
for (ReleaseGroupBrowseResult.ReleaseGroupDto rg : newDtos) {
|
||||||
|
newMbids.add(rg.id);
|
||||||
|
upsertOne(artist, rg);
|
||||||
|
}
|
||||||
|
List<ReleaseGroupEntity> existing = ReleaseGroupEntity.list("artist", artist);
|
||||||
|
for (ReleaseGroupEntity rg : existing) {
|
||||||
|
if (!newMbids.contains(rg.mbid)) {
|
||||||
|
rg.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void markSynced(String mbid) {
|
public void markSynced(String mbid) {
|
||||||
FollowedArtist artist = FollowedArtist.findByMbid(mbid);
|
FollowedArtist artist = FollowedArtist.findByMbid(mbid);
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import jakarta.ws.rs.GET;
|
|||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
@Path("/artists")
|
@Path("/artists")
|
||||||
public class ArtistResource {
|
public class ArtistResource {
|
||||||
@@ -36,6 +38,10 @@ public class ArtistResource {
|
|||||||
@Location("fragments/feed-list.html")
|
@Location("fragments/feed-list.html")
|
||||||
Template fragments_feed_list;
|
Template fragments_feed_list;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@Location("fragments/feed-row.html")
|
||||||
|
Template fragments_feed_row;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@Location("fragments/artist-row.html")
|
@Location("fragments/artist-row.html")
|
||||||
Template fragments_artist_row;
|
Template fragments_artist_row;
|
||||||
@@ -61,16 +67,16 @@ public class ArtistResource {
|
|||||||
dto.area = new ArtistSearchResult.ArtistDto.Area();
|
dto.area = new ArtistSearchResult.ArtistDto.Area();
|
||||||
dto.area.name = area;
|
dto.area.name = area;
|
||||||
}
|
}
|
||||||
FollowedArtist artist = artistService.follow(dto);
|
artistService.follow(dto);
|
||||||
syncService.syncArtist(artist.mbid);
|
CompletableFuture.runAsync(() -> syncService.syncArtist(mbid));
|
||||||
return feedFragment(0);
|
return feedFragment(0, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/{mbid}")
|
@Path("/{mbid}")
|
||||||
public TemplateInstance unfollow(@PathParam("mbid") String mbid) {
|
public Response unfollow(@PathParam("mbid") String mbid) {
|
||||||
artistService.unfollow(mbid);
|
artistService.unfollow(mbid);
|
||||||
return feedFragment(0);
|
return Response.ok("").build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -85,7 +91,8 @@ public class ArtistResource {
|
|||||||
FollowedArtist artist = artistService.findArtist(mbid);
|
FollowedArtist artist = artistService.findArtist(mbid);
|
||||||
List<ArtistTypeSetting> settings = artist != null ? artistService.settingsFor(artist) : List.of();
|
List<ArtistTypeSetting> settings = artist != null ? artistService.settingsFor(artist) : List.of();
|
||||||
return fragments_artist_row.data("artist", artist)
|
return fragments_artist_row.data("artist", artist)
|
||||||
.data("settings", settings);
|
.data("settings", settings)
|
||||||
|
.data("syncing", syncService.isSyncing(mbid));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -93,15 +100,16 @@ public class ArtistResource {
|
|||||||
public TemplateInstance toggleType(@PathParam("mbid") String mbid,
|
public TemplateInstance toggleType(@PathParam("mbid") String mbid,
|
||||||
@FormParam("primaryType") String primaryType,
|
@FormParam("primaryType") String primaryType,
|
||||||
@FormParam("enabled") boolean enabled) {
|
@FormParam("enabled") boolean enabled) {
|
||||||
|
artistService.setTypeEnabled(mbid, primaryType, enabled);
|
||||||
|
CompletableFuture.runAsync(() -> syncService.resyncArtist(mbid));
|
||||||
FollowedArtist artist = artistService.findArtist(mbid);
|
FollowedArtist artist = artistService.findArtist(mbid);
|
||||||
if (artist != null) {
|
List<ArtistTypeSetting> settings = artist != null ? artistService.settingsFor(artist) : List.of();
|
||||||
artistService.setTypeEnabled(artist, primaryType, enabled);
|
return fragments_artist_row.data("artist", artist)
|
||||||
syncService.resyncArtist(mbid);
|
.data("settings", settings)
|
||||||
}
|
.data("syncing", true);
|
||||||
return feedFragment(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private TemplateInstance feedFragment(int offset) {
|
private TemplateInstance feedFragment(int offset, boolean autoRefresh, boolean syncing) {
|
||||||
if (offset < 0) {
|
if (offset < 0) {
|
||||||
offset = 0;
|
offset = 0;
|
||||||
}
|
}
|
||||||
@@ -111,6 +119,8 @@ public class ArtistResource {
|
|||||||
return fragments_feed_list.data("rows", rows)
|
return fragments_feed_list.data("rows", rows)
|
||||||
.data("nextOffset", nextOffset)
|
.data("nextOffset", nextOffset)
|
||||||
.data("hasMore", hasMore)
|
.data("hasMore", hasMore)
|
||||||
.data("pageSize", feedService.pageSize());
|
.data("pageSize", feedService.pageSize())
|
||||||
|
.data("autoRefresh", autoRefresh)
|
||||||
|
.data("syncing", syncing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package io.discdrop.resource;
|
|||||||
import io.discdrop.persistence.FollowedArtist;
|
import io.discdrop.persistence.FollowedArtist;
|
||||||
import io.discdrop.persistence.ReleaseGroupEntity;
|
import io.discdrop.persistence.ReleaseGroupEntity;
|
||||||
import io.discdrop.service.FeedService;
|
import io.discdrop.service.FeedService;
|
||||||
|
import io.discdrop.service.SyncService;
|
||||||
import io.quarkus.qute.Location;
|
import io.quarkus.qute.Location;
|
||||||
import io.quarkus.qute.Template;
|
import io.quarkus.qute.Template;
|
||||||
import io.quarkus.qute.TemplateInstance;
|
import io.quarkus.qute.TemplateInstance;
|
||||||
@@ -20,6 +21,9 @@ public class PageResource {
|
|||||||
@Inject
|
@Inject
|
||||||
FeedService feedService;
|
FeedService feedService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SyncService syncService;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
Template index;
|
Template index;
|
||||||
|
|
||||||
@@ -40,7 +44,9 @@ public class PageResource {
|
|||||||
return fragments_feed_list.data("rows", rows)
|
return fragments_feed_list.data("rows", rows)
|
||||||
.data("nextOffset", nextOffset)
|
.data("nextOffset", nextOffset)
|
||||||
.data("hasMore", hasMore)
|
.data("hasMore", hasMore)
|
||||||
.data("pageSize", feedService.pageSize());
|
.data("pageSize", feedService.pageSize())
|
||||||
|
.data("autoRefresh", false)
|
||||||
|
.data("syncing", syncService.isAnySyncing());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -52,6 +58,8 @@ public class PageResource {
|
|||||||
.data("nextOffset", rows.size())
|
.data("nextOffset", rows.size())
|
||||||
.data("hasMore", hasMore)
|
.data("hasMore", hasMore)
|
||||||
.data("pageSize", feedService.pageSize())
|
.data("pageSize", feedService.pageSize())
|
||||||
|
.data("autoRefresh", false)
|
||||||
|
.data("syncing", syncService.isAnySyncing())
|
||||||
.data("followedCount", FollowedArtist.count())
|
.data("followedCount", FollowedArtist.count())
|
||||||
.data("feedCount", ReleaseGroupEntity.count());
|
.data("feedCount", ReleaseGroupEntity.count());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ public class ArtistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void setTypeEnabled(FollowedArtist artist, String primaryType, boolean enabled) {
|
public void setTypeEnabled(String mbid, String primaryType, boolean enabled) {
|
||||||
|
FollowedArtist artist = FollowedArtist.findByMbid(mbid);
|
||||||
|
if (artist == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (ArtistTypeSetting s : ArtistTypeSetting.findByArtist(artist)) {
|
for (ArtistTypeSetting s : ArtistTypeSetting.findByArtist(artist)) {
|
||||||
if (s.primaryType.equalsIgnoreCase(primaryType)) {
|
if (s.primaryType.equalsIgnoreCase(primaryType)) {
|
||||||
s.enabled = enabled;
|
s.enabled = enabled;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.discdrop.service;
|
|||||||
|
|
||||||
import io.discdrop.persistence.AppSetting;
|
import io.discdrop.persistence.AppSetting;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
@@ -22,6 +23,7 @@ public class SettingsService {
|
|||||||
return parseTypes(raw);
|
return parseTypes(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public void setDefaultPrimaryTypes(Set<String> types) {
|
public void setDefaultPrimaryTypes(Set<String> types) {
|
||||||
AppSetting.set(KEY_DEFAULT_PRIMARY_TYPES, String.join(",", types));
|
AppSetting.set(KEY_DEFAULT_PRIMARY_TYPES, String.join(",", types));
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,7 @@ public class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public void setSyncScheduleHours(int hours) {
|
public void setSyncScheduleHours(int hours) {
|
||||||
AppSetting.set(KEY_SYNC_SCHEDULE_HOURS, String.valueOf(hours));
|
AppSetting.set(KEY_SYNC_SCHEDULE_HOURS, String.valueOf(hours));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import jakarta.enterprise.context.ApplicationScoped;
|
|||||||
import jakarta.enterprise.event.Observes;
|
import jakarta.enterprise.event.Observes;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
@@ -31,6 +34,8 @@ public class SyncService {
|
|||||||
@Inject
|
@Inject
|
||||||
SettingsService settingsService;
|
SettingsService settingsService;
|
||||||
|
|
||||||
|
private final Set<String> syncing = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
private final ScheduledExecutorService executor =
|
private final ScheduledExecutorService executor =
|
||||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
Thread t = new Thread(r, "discdrop-sync");
|
Thread t = new Thread(r, "discdrop-sync");
|
||||||
@@ -73,7 +78,17 @@ public class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isSyncing(String mbid) {
|
||||||
|
return syncing.contains(mbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAnySyncing() {
|
||||||
|
return !syncing.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
public void syncArtist(String mbid) {
|
public void syncArtist(String mbid) {
|
||||||
|
syncing.add(mbid);
|
||||||
|
try {
|
||||||
Set<String> enabledTypes = artistService.enabledTypes(mbid);
|
Set<String> enabledTypes = artistService.enabledTypes(mbid);
|
||||||
String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes);
|
String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes);
|
||||||
|
|
||||||
@@ -90,10 +105,34 @@ public class SyncService {
|
|||||||
more = page.releaseGroups.size() == limit && offset < page.count;
|
more = page.releaseGroups.size() == limit && offset < page.count;
|
||||||
}
|
}
|
||||||
repo.markSynced(mbid);
|
repo.markSynced(mbid);
|
||||||
|
} finally {
|
||||||
|
syncing.remove(mbid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void resyncArtist(String mbid) {
|
public void resyncArtist(String mbid) {
|
||||||
repo.deleteByArtistMbid(mbid);
|
syncing.add(mbid);
|
||||||
syncArtist(mbid);
|
try {
|
||||||
|
Set<String> enabledTypes = artistService.enabledTypes(mbid);
|
||||||
|
String typeFilter = enabledTypes.isEmpty() ? null : String.join("|", enabledTypes);
|
||||||
|
|
||||||
|
List<ReleaseGroupBrowseResult.ReleaseGroupDto> all = new ArrayList<>();
|
||||||
|
int limit = 100;
|
||||||
|
int offset = 0;
|
||||||
|
boolean more = true;
|
||||||
|
while (more) {
|
||||||
|
ReleaseGroupBrowseResult page = mbzService.browseReleaseGroups(mbid, typeFilter, limit, offset);
|
||||||
|
if (page == null || page.releaseGroups == null || page.releaseGroups.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
all.addAll(page.releaseGroups);
|
||||||
|
offset += page.releaseGroups.size();
|
||||||
|
more = page.releaseGroups.size() == limit && offset < page.count;
|
||||||
|
}
|
||||||
|
repo.replaceForArtist(mbid, all);
|
||||||
|
repo.markSynced(mbid);
|
||||||
|
} finally {
|
||||||
|
syncing.remove(mbid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* DiscDrop custom styles */
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
/* Hide empty search dropdown */
|
||||||
|
#search-dropdown:empty { display: none; }
|
||||||
|
|
||||||
|
/* Remove native search input artifacts */
|
||||||
|
input[type="text"]::-webkit-search-cancel-button,
|
||||||
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover art in feed */
|
||||||
|
.cover-art {
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 469 B After Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 676 B After Width: | Height: | Size: 676 B |
|
Before Width: | Height: | Size: 418 B After Width: | Height: | Size: 418 B |
@@ -1,3 +0,0 @@
|
|||||||
/* DiscDrop custom styles */
|
|
||||||
html { scroll-behavior: smooth; }
|
|
||||||
#search-dropdown:empty { display: none; }
|
|
||||||
@@ -4,22 +4,25 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>DiscDrop – Artists</title>
|
<title>DiscDrop – Artists</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg"/>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/[email protected]"></script>
|
<script src="https://unpkg.com/[email protected]"></script>
|
||||||
<link rel="stylesheet" href="/static/css/app.css"/>
|
<link rel="stylesheet" href="/css/app.css"/>
|
||||||
<script src="/static/js/app.js" defer></script>
|
<script src="/js/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300">
|
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300">
|
||||||
<div class="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
|
<div class="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<img src="/static/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
|
<img src="/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
|
||||||
<span class="text-xl font-bold">DiscDrop</span>
|
<span class="text-xl font-bold">DiscDrop</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="/" class="btn btn-ghost btn-sm">Feed</a>
|
<a href="/" class="btn btn-ghost btn-sm">Feed</a>
|
||||||
|
<a href="/rss" class="btn btn-ghost btn-circle" aria-label="RSS feed" title="RSS feed">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"/></svg>
|
||||||
|
</a>
|
||||||
<button id="theme-switcher" class="btn btn-ghost btn-circle" aria-label="Toggle theme" onclick="DiscDrop.toggleTheme()">
|
<button id="theme-switcher" class="btn btn-ghost btn-circle" aria-label="Toggle theme" onclick="DiscDrop.toggleTheme()">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -36,7 +39,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{#else}
|
{#else}
|
||||||
{#for artist in artists}
|
{#for artist in artists}
|
||||||
<div hx-get="/artists/{artist.mbid}/row" hx-trigger="revealed" hx-swap="innerHTML">
|
<div id="artist-{artist.mbid}"
|
||||||
|
hx-get="/artists/{artist.mbid}/row"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML">
|
||||||
<div class="loading loading-spinner loading-sm"></div>
|
<div class="loading loading-spinner loading-sm"></div>
|
||||||
</div>
|
</div>
|
||||||
{/for}
|
{/for}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-xs text-error"
|
<button class="btn btn-ghost btn-xs text-error"
|
||||||
hx-delete="/artists/{artist.mbid}"
|
hx-delete="/artists/{artist.mbid}"
|
||||||
hx-target="closest div.space-y-3"
|
hx-target="#artist-{artist.mbid}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="delete"
|
||||||
hx-on::after-request="DiscDrop.refreshFeed()">
|
hx-on::after-request="DiscDrop.refreshFeed()">
|
||||||
Unfollow
|
Unfollow
|
||||||
</button>
|
</button>
|
||||||
@@ -20,17 +20,30 @@
|
|||||||
{#for s in settings}
|
{#for s in settings}
|
||||||
<label class="label cursor-pointer gap-1 badge badge-outline py-2">
|
<label class="label cursor-pointer gap-1 badge badge-outline py-2">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
|
name="enabled"
|
||||||
class="checkbox checkbox-xs"
|
class="checkbox checkbox-xs"
|
||||||
value="true"
|
value="true"
|
||||||
{#if s.enabled}checked{/if}
|
{#if s.enabled}checked{/if}
|
||||||
hx-post="/artists/{artist.mbid}/types"
|
hx-post="/artists/{artist.mbid}/types"
|
||||||
hx-vals='{"primaryType":"{s.primaryType}"}'
|
hx-vals='{"primaryType":"{s.primaryType}"}'
|
||||||
hx-include="this"
|
hx-include="this"
|
||||||
hx-target="#feed-list"
|
hx-target="#artist-{artist.mbid}"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-on::after-request="DiscDrop.refreshFeed()"/>
|
hx-on::after-request="DiscDrop.refreshFeed()"/>
|
||||||
<span class="text-xs">{s.primaryType}</span>
|
<span class="text-xs">{s.primaryType}</span>
|
||||||
</label>
|
</label>
|
||||||
{/for}
|
{/for}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if syncing?? && syncing}
|
||||||
|
<div class="flex items-center gap-2 text-sm text-base-content/60 pt-1">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
<span>Syncing releases from MusicBrainz…</span>
|
||||||
|
</div>
|
||||||
|
<div hx-get="/artists/{artist.mbid}/row"
|
||||||
|
hx-target="#artist-{artist.mbid}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="load delay:3s"
|
||||||
|
class="hidden"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
{#if syncing?? && syncing}
|
||||||
|
<div class="flex items-center justify-center gap-2 py-2 text-base-content/60">
|
||||||
|
<span class="loading loading-spinner loading-sm text-primary"></span>
|
||||||
|
<span class="text-sm">Fetching releases from MusicBrainz…</span>
|
||||||
|
</div>
|
||||||
|
<div hx-get="/feed?offset=0"
|
||||||
|
hx-target="#feed-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="load delay:3s"
|
||||||
|
class="hidden"></div>
|
||||||
|
{/if}
|
||||||
{#for row in rows}
|
{#for row in rows}
|
||||||
{#include fragments/feed-row.html row=row /}
|
{#include fragments/feed-row.html row=row /}
|
||||||
{/for}
|
{/for}
|
||||||
@@ -10,7 +21,7 @@
|
|||||||
Load more
|
Load more
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !hasMore && rows.empty}
|
{#if !hasMore && rows.empty && !(syncing?? && syncing)}
|
||||||
<div class="text-center text-base-content/60 py-12">
|
<div class="text-center text-base-content/60 py-12">
|
||||||
<p class="text-lg">No releases yet.</p>
|
<p class="text-lg">No releases yet.</p>
|
||||||
<p class="text-sm">Search for an artist above and click <strong>Follow</strong> to start tracking their release groups.</p>
|
<p class="text-sm">Search for an artist above and click <strong>Follow</strong> to start tracking their release groups.</p>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<div class="flex gap-3 items-center bg-base-100 rounded-box border border-base-300 p-3 shadow-sm">
|
<div class="flex gap-3 items-center bg-base-100 rounded-box border border-base-300 p-2.5 shadow-sm">
|
||||||
<img src="https://coverartarchive.org/release-group/{row.mbid}/front"
|
<img src="https://coverartarchive.org/release-group/{row.mbid}/front"
|
||||||
alt="cover"
|
alt="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="w-16 h-16 rounded object-cover bg-base-200"
|
class="cover-art w-40 h-40 object-cover shrink-0"
|
||||||
onerror="this.onerror=null;this.src='/static/img/no-cover.svg'"/>
|
onerror="this.onerror=null;this.src='/img/no-cover.svg'"/>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0 flex flex-col justify-center gap-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="font-semibold text-sm text-base-content/70 truncate">{row.artist.name}</div>
|
||||||
<span class="font-semibold truncate">{row.artist.name}</span>
|
<a href="{row.mbzUrl}" target="_blank" rel="noopener"
|
||||||
{#if row.primaryType??}<span class="badge badge-primary badge-sm">{row.primaryType}</span>{/if}
|
class="link link-hover text-base font-bold leading-tight block truncate">
|
||||||
{#if row.secondaryTypes??}<span class="badge badge-ghost badge-sm">{row.secondaryTypes}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<a href="{row.mbzUrl}" target="_blank" rel="noopener" class="link link-hover text-base-content/90 block truncate">
|
|
||||||
{row.title}
|
{row.title}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<div class="flex flex-wrap items-center gap-2 mt-0.5">
|
||||||
|
{#if row.primaryType??}<span class="badge badge-primary badge-sm">{row.primaryType}</span>{/if}
|
||||||
<div class="text-right text-sm text-base-content/70 shrink-0">
|
{#if row.secondaryTypes??}<span class="badge badge-ghost badge-sm">{row.secondaryTypes}</span>{/if}
|
||||||
|
<span class="text-sm text-base-content/60">
|
||||||
{#if row.firstReleaseDateRaw??}{row.firstReleaseDateRaw}{#else}—{/if}
|
{#if row.firstReleaseDateRaw??}{row.firstReleaseDateRaw}{#else}—{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,38 +4,37 @@
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title>DiscDrop</title>
|
<title>DiscDrop</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg"/>
|
||||||
<link rel="alternate" type="application/rss+xml" title="DiscDrop – New Releases" href="/rss"/>
|
<link rel="alternate" type="application/rss+xml" title="DiscDrop – New Releases" href="/rss"/>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css"/>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/[email protected]"></script>
|
<script src="https://unpkg.com/[email protected]"></script>
|
||||||
<link rel="stylesheet" href="/static/css/app.css"/>
|
<link rel="stylesheet" href="/css/app.css"/>
|
||||||
<script src="/static/js/app.js" defer></script>
|
<script src="/js/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300">
|
<header class="sticky top-0 z-30 bg-base-100/90 backdrop-blur border-b border-base-300">
|
||||||
<div class="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
|
<div class="navbar flex flex-wrap justify-center gap-4 px-4 py-3 max-w-5xl mx-auto">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<img src="/static/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
|
<img src="/img/logo.svg" alt="DiscDrop logo" class="h-9 w-9"/>
|
||||||
<span class="text-xl font-bold">DiscDrop</span>
|
<span class="text-xl font-bold">DiscDrop</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="search-wrap" class="relative flex-1 min-w-[220px] max-w-xl">
|
<div id="search-wrap" class="relative flex-1 min-w-[220px] max-w-xl">
|
||||||
<form id="search-form" class="join w-full" autocomplete="off">
|
|
||||||
<input
|
<input
|
||||||
id="search-input"
|
id="search-input"
|
||||||
type="search"
|
type="text"
|
||||||
name="q"
|
name="q"
|
||||||
placeholder="Search MusicBrainz artists…"
|
placeholder="Search MusicBrainz artists…"
|
||||||
class="input input-bordered join-item w-full"
|
class="input input-bordered w-full"
|
||||||
|
autocomplete="off"
|
||||||
hx-get="/search/artists"
|
hx-get="/search/artists"
|
||||||
hx-trigger="input changed delay:300ms"
|
hx-trigger="input changed delay:300ms"
|
||||||
hx-target="#search-dropdown"
|
hx-target="#search-dropdown"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-include="this"/>
|
hx-include="this"/>
|
||||||
</form>
|
|
||||||
<div id="search-dropdown"
|
<div id="search-dropdown"
|
||||||
class="absolute left-0 right-0 z-40 mt-1 bg-base-100 rounded-box shadow-lg border border-base-300 max-h-96 overflow-y-auto"></div>
|
class="absolute left-0 right-0 top-full z-40 mt-1 bg-base-100 rounded-box shadow-lg border border-base-300 max-h-96 overflow-y-auto"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -57,6 +56,10 @@
|
|||||||
<svg id="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
<svg id="theme-icon-sun" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<a href="/rss" class="btn btn-ghost btn-circle" aria-label="RSS feed" title="RSS feed">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"/></svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="/artists" class="btn btn-ghost btn-sm" aria-label="Artists">Artists</a>
|
<a href="/artists" class="btn btn-ghost btn-sm" aria-label="Artists">Artists</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user