Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6e4eb0c
chore(refactor): Standarized api and htmx handlers
contre95 May 20, 2026
850ace7
chore(refactor): Standarized api and htmx handlers.go
contre95 May 20, 2026
8177901
chore(refactor): Standarized text responses
contre95 May 20, 2026
d0377eb
chore(refactor): Standarized others
contre95 May 20, 2026
e31ac06
chore(refactor): Remove duplicated route
contre95 May 20, 2026
9b1b7c4
chore(refactor): Edit duplicate route view
contre95 May 20, 2026
c55c2ca
chore(refactor): Edit duplicate route view
contre95 May 20, 2026
606cdd1
chore(refactor): Resource() + config + metadata
contre95 May 20, 2026
ee09c2c
chore(refactor): Resource() + config + metadata
contre95 May 21, 2026
745cf8d
feat(docs): New API
contre95 May 21, 2026
c583738
feat(docs): Remove Button handlers for Providers
contre95 May 21, 2026
58ebdd5
Merge pull request #162 from contre95/fix/api_htmx
contre95 May 21, 2026
84b1a68
feat(streaming): streaming feature
contre95 Jun 5, 2026
6f45d6c
fix: Reduce time of count refresh
contre95 Jun 6, 2026
af4955d
feat: player on edit for + download
contre95 Jun 6, 2026
37c4ebc
feat: download {{.Track.Path}}
contre95 Jun 6, 2026
0b0992b
feat: replace dup q card text
contre95 Jun 6, 2026
4158e2b
fix: infinite indicator rendering
contre95 Jun 6, 2026
899f036
feat: Support symlinks in streaming feature
contre95 Jun 6, 2026
9b6c664
fix: downlaod file name
contre95 Jun 6, 2026
870d041
fix: Remove missing ui endpoints
contre95 Jun 6, 2026
ce20247
fix: Remove dependencies
contre95 Jun 7, 2026
ea5d0a9
fix: refactor streaming service
contre95 Jun 7, 2026
240dbb6
fix: Audio mime types
contre95 Jun 7, 2026
9ea3bfc
Merge branch 'dev' into feat/play_on_queue
contre95 Jun 7, 2026
695d6ac
Merge pull request #165 from contre95/feat/play_on_queue
contre95 Jun 7, 2026
0934fa1
fix: remove ui from sidebar+docs
contre95 Jun 7, 2026
9a3da62
Code rabbit fixed
contre95 Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions FEATURE_SPEC_KIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ All of these follow the same approach:
3. Normal outcomes complete silently; edge cases (duplicate found, lyrics already exist, etc.) are added to a queue
4. User visits the queue sub-section to review and take action on each item

The landing `sections/analyze.html` shows a list of all `analyze_`-prefixed jobs via `/ui/jobs/list?prefix=analyze_`. Adding a new analyze-section feature means registering its job type with `analyze_` prefix so it appears there automatically.
The landing `sections/analyze.html` shows a list of all `analyze_`-prefixed jobs via `/jobs/list?prefix=analyze_`. Adding a new analyze-section feature means registering its job type with `analyze_` prefix so it appears there automatically.

## Section Rendering and the HTMX URL-Push Problem

Expand Down Expand Up @@ -230,7 +230,7 @@ Conditional rendering based on `.Section` — add new entries here for every nav
<h1 class="text-3xl font-bold text-slate-800 dark:text-white mb-8">
Your Feature
</h1>
<div hx-get="/ui/yourfeature/partial" hx-trigger="load">
<div hx-get="/yourfeature/partial" hx-trigger="load">
Loading...
</div>
</div>
Expand Down Expand Up @@ -380,7 +380,7 @@ c.Set("HX-Trigger", "refreshJobList") // ← makes the job card appear instant
if c.Get("HX-Request") == "true" {
return c.Render("toast/toastOk", fiber.Map{"Msg": "Analysis started"})
}
return c.Redirect("/ui/analyze/myfeature")
return c.Redirect("/analyze/myfeature")
```

The job list containers in section templates listen for this event via `hx-trigger="load, refreshJobList from:body"` and re-fetch their content when it fires.
Expand Down
211 changes: 211 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# SoulSolid API Reference

Most endpoints that return Resource responses or support both HTML and API clients perform content negotiation.
HTMX sends `HX-Request: true` automatically; anything else is treated as an API client.
Resource responses additionally negotiate via the `Accept` header (`Accept: application/json` returns JSON metadata instead of the binary).
Some endpoints are always JSON or HTMX-only and do not perform `HX-Request`-based negotiation.

**Response types**

| Type | HTMX | API (no `HX-Request`) |
|------|------|-----------------------|
| **Section** | Section partial (`sections/<name>`) | Full page via `main.html` |
| **Partial** | HTML fragment | Same data as JSON |
| **Text** | Plain string | `{"key":"…","value":…}` |
| **Toast OK** | Success toast | `{"message":"…"}` |
| **Toast Err** | Error toast | `{"error":"…"}` + HTTP status |
| **Toast Job** | Success toast | `202 {"job_id":"…"}` |
| **Resource** | Binary / file (default) | `{"type":"…","url":"…"}` when `Accept: application/json` |
| **JSON** | — | Always JSON, no negotiation |

---

## UI / Dashboard

| Method | Route | Type | HTMX | API / Browser |
|--------|-------|------|------|---------------|
| GET | `/` | Section | `sections/dashboard` | full page |
| GET | `/dashboard` | Section | `sections/dashboard` | full page |
| GET | `/analyze` | Section | `sections/analyze` | full page |
| GET | `/dashboard/quick-actions` | Partial | HTML card | JSON data |

---

## Config

| Method | Route | Type | HTMX | API / Browser |
|--------|-------|------|------|---------------|
| GET | `/settings` | Section | `sections/settings` | full page |
| GET | `/config/form` | Partial | HTML form | JSON config |
| PUT | `/settings` | Toast OK | success toast | `{"message":"…"}` |
| GET | `/config` | JSON | — | config struct as JSON |
| GET | `/config?fmt=yaml` | — | raw `text/yaml` | raw `text/yaml` |
| GET | `/config/database/download` | Resource | SQLite file download | `{"type":"application/octet-stream","url":"…"}` |

---

## Library

| Method | Route | Type | HTMX | API |
|--------|-------|------|------|-----|
| GET | `/library` | Section | `sections/library` | full page |
| GET | `/library/table` | Partial | HTML table | JSON data |
| GET | `/library/tracks/:trackId/overview` | Partial | HTML panel | JSON data |
| GET | `/library/search` | Partial | HTML results list | JSON results + pagination |
| GET | `/library/artists/count` | Text | `"N"` | `{"key":"artists_count","value":N}` |
| GET | `/library/albums/count` | Text | `"N"` | `{"key":"albums_count","value":N}` |
| GET | `/library/tracks/count` | Text | `"N tracks"` | `{"key":"tracks_count","value":N}` |
| GET | `/library/storage/size` | Text | `"X GB"` | `{"key":"storage_size_bytes","value":N}` |
| GET | `/library/artists/:id` | JSON | — | artist object |
| GET | `/library/albums/:id` | JSON | — | album object |
| GET | `/library/tracks/:id` | JSON | — | track object |
| GET | `/library/tree` | Text | plain tree string | `{"key":"file_tree","value":"…"}` |
| GET | `/library/tracks/:id/lyrics` | Text | plain lyrics | `{"key":"lyrics","value":"…"}` |
| DELETE | `/library/tracks/:trackId` | Toast OK | success toast | `{"message":"…"}` |
| DELETE | `/library/albums/:albumId` | Toast OK | success toast | `{"message":"…"}` |
| DELETE | `/library/artists/:artistId` | Toast OK | success toast | `{"message":"…"}` |

---

## Tag / Metadata

| Method | Route | Type | HTMX | API / Browser |
|--------|-------|------|------|---------------|
| GET | `/tag/:trackId` | Section | `sections/tag` | full page |
| GET | `/tag/:trackId?source=db` | Section | `sections/tag` (reads DB) | full page |
| POST | `/tag/:trackId` | Toast OK | success toast | `{"message":"…"}` |
| GET | `/tag/:trackId/:provider` | Section | `sections/tag` (provider data) | full page |
| GET | `/tag/:trackId/artwork` | Resource | image bytes | `{"type":"image/…","url":"…"}` |
| GET | `/tag/:trackId/fingerprint` | Toast OK | success toast | `{"message":"…"}` |
| GET | `/tag/:trackId/fingerprint/view` | Text | fingerprint string | `{"key":"fingerprint","value":"…"}` |
| GET | `/tag/:trackId/search/:provider` | Partial | HTML modal | JSON results |
| GET | `/tag/:trackId/select/:provider` | Partial | HTML form | JSON track data |
| POST | `/analyze/acoustid` | Toast Job | success toast | `202 {"job_id":"…"}` |
| GET | `/analyze/metadata` | Section | `sections/analyze_metadata` | full page |

---

## Importing

| Method | Route | Type | HTMX | API |
|--------|-------|------|------|-----|
| GET | `/import` | Section | `sections/import` | full page |
| GET | `/import/directory/form` | Partial | HTML form | JSON data |
| GET | `/import/queue/items` | Partial | HTML list | JSON items |
| GET | `/import/queue/items/grouped` | Partial | HTML grouped list | JSON groups |
| GET | `/import/queue/header` | Partial | HTML header | JSON data |
| GET | `/import/queue/:id/artwork` | Resource | image bytes | `{"type":"image/…","url":"…"}` |
| GET | `/import/queue/count` | Text | `"(N)"` or `""` | `{"key":"queue_count","value":N}` |
| POST | `/import/directory` | Toast Job | success toast | `202 {"job_id":"…"}` |
| POST | `/import/queue/:id/:action` | Toast OK | success toast | `{"message":"…"}` |
| POST | `/import/queue/group/:groupType/:groupKey/:action` | Toast OK | success toast | `{"message":"…"}` |
| POST | `/import/queue/clear` | Toast OK | success toast | `{"message":"…"}` |
| POST | `/import/prune/download-path` | Toast OK | success toast | `{"message":"…"}` |
| POST | `/import/watcher/toggle` | Toast OK | success toast | `{"message":"…"}` |
| GET | `/import/watcher/status` | Partial | HTML status | JSON status |
| GET | `/import/watcher/toggle-state` | Partial | HTML toggle | JSON state |

---

## Jobs

| Method | Route | Type | HTMX | API |
|--------|-------|------|------|-----|
| GET | `/jobs` | Section | `sections/jobs` | full page |
| GET | `/jobs/active` | Partial | HTML active list | JSON jobs |
| GET | `/jobs/list` | Partial | HTML list | JSON jobs |
| GET | `/jobs/latest` | Partial | HTML latest list | JSON jobs |
| GET | `/jobs/count` | Text | `"(N)"` or `""` | `{"key":"jobs_count","value":N}` |
| POST | `/jobs/clear-finished` | Toast OK | success toast | `{"message":"…"}` |
| GET | `/jobs/all` | JSON | — | `[{job, _links}]` |
| POST | `/jobs/start/:type` | Toast Job | success toast | `202 {"job_id":"…"}` |
| GET | `/jobs/:id` | JSON | — | `{job, _links}` |
| GET | `/jobs/:id/progress` | Partial | HTML progress bar | JSON progress |
| GET | `/jobs/:id/logs` | — | plain text | plain text |
| GET | `/jobs/:id/logs?color=true` | — | colored HTML fragment | fullscreen HTML page |
| POST | `/jobs/:id/cancel` | Partial | HTML job card | JSON job data |

---

## Downloading

| Method | Route | Type | HTMX | API |
|--------|-------|------|------|-----|
| GET | `/downloads` | Section | `sections/download` | full page |
| GET | `/downloads/chart/tracks` | Partial | HTML chart | JSON tracks |
| POST | `/downloads/search` | Partial | HTML results | JSON results |
| POST | `/downloads/search/albums` | Partial | HTML results | JSON albums |
| POST | `/downloads/search/tracks` | Partial | HTML results | JSON tracks |
| GET | `/downloads/album/:albumId/tracks` | Partial | HTML track list | JSON tracks |
| GET | `/downloads/user/info` | Partial | HTML user info | JSON user info |
| GET | `/downloads/capabilities` | JSON | — | capabilities object |
| POST | `/downloads/track` | Toast Job | success toast | `202 {"job_id":"…"}` |
| POST | `/downloads/album` | Toast Job | success toast | `202 {"job_id":"…"}` |
| POST | `/downloads/artist` | Toast Job | success toast | `202 {"job_id":"…"}` |
| POST | `/downloads/tracks` | Toast Job | success toast | `202 {"job_id":"…"}` |
| POST | `/downloads/playlist` | Toast Job | success toast | `202 {"job_id":"…"}` |

---

## Lyrics

| Method | Route | Type | HTMX | API |
|--------|-------|------|------|-----|
| GET | `/analyze/lyrics` | Section | `sections/analyze_lyrics` | full page |
| GET | `/tag/:trackId/lyrics/text/:provider` | — | plain lyrics text | `{"track_id":"…","lyrics":"…"}` |
| GET | `/library/tracks/:id/lyrics` | Text | plain lyrics | `{"key":"lyrics","value":"…"}` |
| GET | `/lyrics/queue/header` | Partial | HTML header | JSON data |
| GET | `/lyrics/queue/items` | Partial | HTML list | JSON items |
| GET | `/lyrics/queue/items/grouped` | Partial | HTML grouped list | JSON groups |
| GET | `/lyrics/queue/count` | Text | `"(N)"` or `""` | `{"key":"queue_count","value":N}` |
| GET | `/lyrics/queue/:id/new_lyrics` | Text | plain lyrics | `{"key":"lyrics","value":"…"}` |
| POST | `/lyrics/queue/:id/:action` | Toast OK | success toast | `{"message":"…"}` |
| POST | `/lyrics/queue/group/:groupType/:groupKey/:action` | Toast OK | success toast | `{"message":"…"}` |
| POST | `/lyrics/queue/clear` | Toast OK | success toast | `{"message":"…"}` |
| POST | `/analyze/lyrics` | Toast Job | success toast | `202 {"job_id":"…"}` |

---

## Playlists

| Method | Route | Type | HTMX | API / Browser |
|--------|-------|------|------|---------------|
| GET | `/playlists` | Section | `sections/playlists` | full page |
| GET | `/playlists/:id` | Partial | HTML playlist view | JSON playlist |
| GET | `/playlists/create-modal` | Partial | HTML modal | JSON data |
| GET | `/playlists/:type/:id/playlists` | Partial | HTML list | JSON playlists |
| GET | `/playlists/:id/export` | Resource | `.m3u` file | `{"type":"audio/x-mpegurl","url":"…"}` |
| POST | `/playlists/` | Toast OK | success toast | `{"message":"…"}` |
| PUT | `/playlists/:id` | Toast OK | success toast | `{"message":"…"}` |
| DELETE | `/playlists/:id` | Toast OK | success toast | `{"message":"…"}` |
| POST | `/playlists/items` | Toast OK | success toast | `{"message":"…"}` |
| DELETE | `/playlists/:playlistId/tracks/:trackId` | Toast OK | success toast | `{"message":"…"}` |

---

## Reorganize

| Method | Route | Type | HTMX | API |
|--------|-------|------|------|-----|
| GET | `/analyze/files` | Section | `sections/analyze_files` | full page |
| POST | `/analyze/reorganize` | Toast Job | success toast | `202 {"job_id":"…"}` |

---

## Metrics

| Method | Route | Type | HTMX | API |
|--------|-------|------|------|-----|
| GET | `/metrics/overview` | Partial | HTML overview | JSON metrics |
| GET | `/metrics/charts/genre` | Partial | HTML chart | JSON data |
| GET | `/metrics/charts/year` | Partial | HTML chart | JSON data |
| GET | `/metrics/charts/format` | Partial | HTML chart | JSON data |
| GET | `/metrics/charts/metadata` | Partial | HTML chart | JSON data |

---

## Streaming

| Method | Route | Type | HTMX | API / Browser |
|--------|-------|------|------|---------------|
| GET | `/stream?path=<encoded-path>` | Resource | audio bytes | `{"type":"audio/…","url":"…"}` when `Accept: application/json` |
44 changes: 14 additions & 30 deletions src/features/config/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"

"github.com/contre95/soulsolid/src/features/hosting/respond"
"github.com/gofiber/fiber/v2"
)

Expand All @@ -24,14 +25,7 @@ func NewHandler(configManager *Manager) *Handler {
// RenderSettingsSection renders the settings form with current configuration values.
func (h *Handler) RenderSettingsSection(c *fiber.Ctx) error {
slog.Debug("RenderSettings handler called")
data := fiber.Map{
"Title": "Settings",
}
if c.Get("HX-Request") != "true" {
data["Section"] = "settings"
return c.Render("main", data)
}
return c.Render("sections/settings", data)
return respond.Section(c, "settings", fiber.Map{"Title": "Settings"})
}

// UpdateSettings handles the form submission to update configuration.
Expand Down Expand Up @@ -128,9 +122,7 @@ func (h *Handler) UpdateSettings(c *fiber.Ctx) error {
} else {
slog.Info("Configuration saved to file successfully")
}
return c.Render("toast/toastOk", fiber.Map{
"Msg": "Configuration updated successfully!",
})
return respond.ToastOk(c, "Configuration updated successfully!")
}

func parseStringSlice(s string) []string {
Expand All @@ -152,24 +144,19 @@ func (h *Handler) GetConfigForm(c *fiber.Ctx) error {
slog.Debug("GetSettingsForm handler called")
config := h.configManager.Get()

return c.Render("config/config_form", fiber.Map{
return respond.Partial(c, "config/config_form", fiber.Map{
"Config": config,
})
}

// GetConfig returns the current configuration in the requested format.
// GetConfig returns the config as JSON, or as raw YAML text when ?fmt=yaml is set.
func (h *Handler) GetConfig(c *fiber.Ctx) error {
// Supporting only one format for now
slog.Debug("GetConfig handler called", "format", c.Query("fmt", "yaml"))
format := c.Query("fmt", "yaml")

switch format {
case "yaml":
slog.Debug("GetConfig handler called")
if c.Query("fmt") == "yaml" {
c.Set("Content-Type", "text/yaml")
return c.SendString(h.configManager.GetYAML())
default:
return c.Status(fiber.StatusBadRequest).SendString("Invalid format. 'yaml' only availabe for now")
}
return c.JSON(h.configManager.Get())
}

// DownloadDatabase serves the database file for download.
Expand All @@ -180,16 +167,13 @@ func (h *Handler) DownloadDatabase(c *fiber.Ctx) error {
dbPath := config.Database.Path

if dbPath == "" {
return c.Status(fiber.StatusBadRequest).SendString("Database path not configured")
return respond.ToastErr(c, fiber.StatusBadRequest, "Database path not configured")
}

// Extract filename from path for download
filename := filepath.Base(dbPath)

// Set headers for file download
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Set("Content-Type", "application/octet-stream")

// Send the file
return c.SendFile(dbPath)
return respond.Resource(c, "application/octet-stream", fmt.Sprintf("%s/config/database/download", c.BaseURL()), func() error {
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Set("Content-Type", "application/octet-stream")
return c.SendFile(dbPath)
})
}
11 changes: 3 additions & 8 deletions src/features/config/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,11 @@ import (

// RegisterRoutes registers the routes for the config feature.
func RegisterRoutes(app *fiber.App, configManager *Manager) {
// Create a new handler for the config feature.
handler := NewHandler(configManager)

/// UI
ui := app.Group("/ui")
ui.Get("/settings", handler.RenderSettingsSection)
ui.Get("/config/form", handler.GetConfigForm)

// APP
app.Post("/settings/update", handler.UpdateSettings)
app.Get("/settings", handler.RenderSettingsSection)
app.Get("/config/form", handler.GetConfigForm)
app.Put("/settings", handler.UpdateSettings)
app.Get("/config", handler.GetConfig)
app.Get("/config/database/download", handler.DownloadDatabase)
}
Loading
Loading