diff --git a/FEATURE_SPEC_KIT.md b/FEATURE_SPEC_KIT.md index 46fcc633..919b3d32 100644 --- a/FEATURE_SPEC_KIT.md +++ b/FEATURE_SPEC_KIT.md @@ -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 @@ -230,7 +230,7 @@ Conditional rendering based on `.Section` — add new entries here for every nav

Your Feature

-
+
Loading...
@@ -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. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..3d6d83d4 --- /dev/null +++ b/docs/api.md @@ -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/`) | 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=` | Resource | audio bytes | `{"type":"audio/…","url":"…"}` when `Accept: application/json` | diff --git a/src/features/config/handlers.go b/src/features/config/handlers.go index 38b3d5dc..9c1228db 100644 --- a/src/features/config/handlers.go +++ b/src/features/config/handlers.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/gofiber/fiber/v2" ) @@ -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. @@ -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 { @@ -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. @@ -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) + }) } diff --git a/src/features/config/routes.go b/src/features/config/routes.go index fe2c6f18..e2036c58 100644 --- a/src/features/config/routes.go +++ b/src/features/config/routes.go @@ -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) } diff --git a/src/features/downloading/handlers.go b/src/features/downloading/handlers.go index ca7923d8..83a6952c 100644 --- a/src/features/downloading/handlers.go +++ b/src/features/downloading/handlers.go @@ -1,11 +1,11 @@ package downloading import ( - "fmt" "log/slog" "strconv" "strings" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/contre95/soulsolid/src/music" "github.com/gofiber/fiber/v2" ) @@ -17,9 +17,7 @@ type Handler struct { // NewHandler creates a new downloading handler func NewHandler(service *Service) *Handler { - return &Handler{ - service: service, - } + return &Handler{service: service} } // RenderDownloadSection renders the download page. @@ -32,15 +30,10 @@ func (h *Handler) RenderDownloadSection(c *fiber.Ctx) error { downloader = cfg.Downloaders.Plugins[0].Name } } - data := fiber.Map{ + return respond.Section(c, "download", fiber.Map{ "Title": "Download", "CurrentDownloader": downloader, - } - if c.Get("HX-Request") != "true" { - data["Section"] = "download" - return c.Render("main", data) - } - return c.Render("sections/download", data) + }) } // SearchRequest represents a search request @@ -57,45 +50,20 @@ func (h *Handler) SearchAlbums(c *fiber.Ctx) error { var req SearchRequest if err := c.BodyParser(&req); err != nil { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Invalid request body", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid request body") } - if req.Query == "" { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Query parameter is required", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Query parameter is required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Query parameter is required") } albums, err := h.service.SearchAlbums(req.Downloader, req.Query, req.Limit) if err != nil { slog.Error("Failed to search albums", "error", err) - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to search albums", - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to search albums", - }) - } - - if c.Get("HX-Request") == "true" { - return h.renderAlbumResults(c, albums, req.Downloader) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to search albums") } - return c.JSON(fiber.Map{ - "albums": albums, + return respond.Partial(c, "downloading/album_results", fiber.Map{ + "Albums": albums, + "Downloader": req.Downloader, }) } @@ -105,45 +73,25 @@ func (h *Handler) SearchTracks(c *fiber.Ctx) error { var req SearchRequest if err := c.BodyParser(&req); err != nil { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Invalid request body", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid request body") } - if req.Query == "" { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Query parameter is required", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Query parameter is required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Query parameter is required") } tracks, err := h.service.SearchTracks(req.Downloader, req.Query, req.Limit) if err != nil { slog.Error("Failed to search tracks", "error", err) - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to search tracks", - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to search tracks", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to search tracks") } - if c.Get("HX-Request") == "true" { - return h.renderTrackResults(c, tracks, req.Downloader) + trackPtrs := make([]*music.Track, len(tracks)) + for i := range tracks { + trackPtrs[i] = &tracks[i] } - return c.JSON(fiber.Map{ - "tracks": tracks, + return respond.Partial(c, "downloading/spotify_track_results", fiber.Map{ + "Tracks": trackPtrs, + "Downloader": req.Downloader, }) } @@ -153,189 +101,106 @@ func (h *Handler) Search(c *fiber.Ctx) error { var req SearchRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid request body") } - if req.Query == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Query parameter is required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Query parameter is required") } - if req.Limit == 0 { req.Limit = 20 } - // Check if it's an HTMX request - if c.Get("HX-Request") == "true" { - // Return HTML for HTMX - switch req.Type { - - case "album": - albums, err := h.service.SearchAlbums(req.Downloader, req.Query, req.Limit) - if err != nil { - slog.Error("Failed to search albums", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to search albums", - }) - } - return h.renderAlbumResults(c, albums, req.Downloader) - case "track": - tracks, err := h.service.SearchTracks(req.Downloader, req.Query, req.Limit) - if err != nil { - slog.Error("Failed to search tracks", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to search tracks", - }) - } - return h.renderTrackResults(c, tracks, req.Downloader) - case "artist": - artists, err := h.service.SearchArtists(req.Downloader, req.Query, req.Limit) - if err != nil { - slog.Error("Failed to search artists", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to search artists", - }) - } - return h.renderArtistResults(c, artists, req.Downloader) - case "link": - result, err := h.service.SearchLinks(req.Downloader, req.Query, req.Limit) - if err != nil { - slog.Error("Failed to search links", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to search links", - }) - } - // Route to appropriate renderer based on result type - switch result.Type { - case "artist": - return h.renderArtistLinkResults(c, result.Artist, result.Albums, req.Downloader) - default: - return h.renderLinkResults(c, result.Tracks, req.Downloader) - } - default: - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Invalid search type", - }) - } - } - - // Return JSON for API switch req.Type { - case "album": albums, err := h.service.SearchAlbums(req.Downloader, req.Query, req.Limit) if err != nil { slog.Error("Failed to search albums", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to search albums", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to search albums") } - return c.JSON(fiber.Map{ - "albums": albums, + return respond.Partial(c, "downloading/album_results", fiber.Map{ + "Albums": albums, + "Downloader": req.Downloader, }) + case "track": tracks, err := h.service.SearchTracks(req.Downloader, req.Query, req.Limit) if err != nil { slog.Error("Failed to search tracks", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to search tracks", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to search tracks") + } + trackPtrs := make([]*music.Track, len(tracks)) + for i := range tracks { + trackPtrs[i] = &tracks[i] } - return c.JSON(fiber.Map{ - "tracks": tracks, + return respond.Partial(c, "downloading/spotify_track_results", fiber.Map{ + "Tracks": trackPtrs, + "Downloader": req.Downloader, }) + case "artist": artists, err := h.service.SearchArtists(req.Downloader, req.Query, req.Limit) if err != nil { slog.Error("Failed to search artists", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to search artists", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to search artists") } - return c.JSON(fiber.Map{ - "artists": artists, + return respond.Partial(c, "downloading/artist_results", fiber.Map{ + "Artists": artists, + "Downloader": req.Downloader, }) + case "link": result, err := h.service.SearchLinks(req.Downloader, req.Query, req.Limit) if err != nil { slog.Error("Failed to search links", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to search links", + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to search links") + } + if c.Get("HX-Request") != "true" { + return c.JSON(result) + } + if result.Type == "artist" { + return c.Render("downloading/artist_link_results", fiber.Map{ + "Artist": result.Artist, + "Albums": result.Albums, + "Downloader": req.Downloader, }) } - return c.JSON(result) - default: - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid search type", + playlistName := "" + if len(result.Tracks) > 0 && result.Tracks[0].Attributes != nil { + playlistName = result.Tracks[0].Attributes["playlist_name"] + } + trackPtrs := make([]*music.Track, len(result.Tracks)) + for i := range result.Tracks { + trackPtrs[i] = &result.Tracks[i] + } + return c.Render("downloading/link_results", fiber.Map{ + "Tracks": trackPtrs, + "Downloader": req.Downloader, + "PlaylistName": playlistName, }) - } -} - -// renderAlbumResults renders album search results as HTML for HTMX -func (h *Handler) renderAlbumResults(c *fiber.Ctx, albums []music.Album, downloader string) error { - return c.Render("downloading/album_results", fiber.Map{ - "Albums": albums, - "Downloader": downloader, - }) -} -// renderTrackResults renders track search results as HTML for HTMX -func (h *Handler) renderTrackResults(c *fiber.Ctx, tracks []music.Track, downloader string) error { - // Convert []music.Track to []*music.Track for template compatibility - trackPtrs := make([]*music.Track, len(tracks)) - for i := range tracks { - trackPtrs[i] = &tracks[i] + default: + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid search type") } - return c.Render("downloading/spotify_track_results", fiber.Map{ - "Tracks": trackPtrs, - "Downloader": downloader, - }) } -// renderLinkResults renders link search results as HTML for HTMX -func (h *Handler) renderLinkResults(c *fiber.Ctx, tracks []music.Track, downloader string) error { - // Check if tracks belong to a playlist - playlistName := "" - if len(tracks) > 0 && tracks[0].Attributes != nil { - playlistName = tracks[0].Attributes["playlist_name"] - } - - // Convert []music.Track to []*music.Track for template compatibility - trackPtrs := make([]*music.Track, len(tracks)) - for i := range tracks { - trackPtrs[i] = &tracks[i] - } - - return c.Render("downloading/link_results", fiber.Map{ - "Tracks": trackPtrs, - "Downloader": downloader, - "PlaylistName": playlistName, - }) +// DownloadTrackRequest represents a download track request +type DownloadTrackRequest struct { + TrackID string `json:"trackId" form:"trackId"` } -// renderArtistLinkResults renders artist link results (artist info + albums) as HTML for HTMX -func (h *Handler) renderArtistLinkResults(c *fiber.Ctx, artist *music.Artist, albums []music.Album, downloader string) error { - return c.Render("downloading/artist_link_results", fiber.Map{ - "Artist": artist, - "Albums": albums, - "Downloader": downloader, - }) +// DownloadAlbumRequest represents a download album request +type DownloadAlbumRequest struct { + AlbumID string `json:"albumId" form:"albumId"` } -// renderArtistResults renders artist search results as HTML for HTMX -func (h *Handler) renderArtistResults(c *fiber.Ctx, artists []music.Artist, downloader string) error { - return c.Render("downloading/artist_results", fiber.Map{ - "Artists": artists, - "Downloader": downloader, - }) +// DownloadArtistRequest represents a download artist request +type DownloadArtistRequest struct { + ArtistID string `json:"artistId" form:"artistId"` } -// DownloadTrackRequest represents a download track request -type DownloadTrackRequest struct { - TrackID string `json:"trackId" form:"trackId"` +// DownloadTracksRequest represents a download tracks request +type DownloadTracksRequest struct { + TrackIDs string `json:"trackIds" form:"trackIds"` // Comma-separated track IDs } // DownloadTrack handles track download requests @@ -344,25 +209,10 @@ func (h *Handler) DownloadTrack(c *fiber.Ctx) error { var req DownloadTrackRequest if err := c.BodyParser(&req); err != nil { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Invalid request body", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid request body") } - if req.TrackID == "" { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Track ID is required", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Track ID is required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Track ID is required") } downloader := strings.Clone(c.Query("downloader", "dummy")) @@ -371,40 +221,10 @@ func (h *Handler) DownloadTrack(c *fiber.Ctx) error { jobID, err := h.service.DownloadTrack(downloader, req.TrackID) if err != nil { slog.Error("Failed to start track download", "error", err) - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to start track download", - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to start download", - }) - } - - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Track download started", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start track download") } - return c.JSON(fiber.Map{ - "jobId": jobID, - "message": "Download started", - }) -} -// DownloadAlbumRequest represents a download album request -type DownloadAlbumRequest struct { - AlbumID string `json:"albumId" form:"albumId"` -} - -// DownloadArtistRequest represents a download artist request -type DownloadArtistRequest struct { - ArtistID string `json:"artistId" form:"artistId"` -} - -// DownloadTracksRequest represents a download tracks request -type DownloadTracksRequest struct { - TrackIDs string `json:"trackIds" form:"trackIds"` // Comma-separated track IDs + return respond.ToastJob(c, jobID, "Track download started") } // DownloadAlbum handles album download requests @@ -413,50 +233,20 @@ func (h *Handler) DownloadAlbum(c *fiber.Ctx) error { var req DownloadAlbumRequest if err := c.BodyParser(&req); err != nil { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Invalid request body", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid request body") } - if req.AlbumID == "" { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Album ID is required", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Album ID is required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Album ID is required") } downloader := strings.Clone(c.Query("downloader", "dummy")) jobID, err := h.service.DownloadAlbum(downloader, req.AlbumID) if err != nil { slog.Error("Failed to start album download", "error", err) - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to start album download", - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to start download", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start album download") } - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Album download started", - }) - } - return c.JSON(fiber.Map{ - "jobId": jobID, - "message": "Download started", - }) + return respond.ToastJob(c, jobID, "Album download started") } // DownloadArtist handles artist download requests @@ -465,50 +255,20 @@ func (h *Handler) DownloadArtist(c *fiber.Ctx) error { var req DownloadArtistRequest if err := c.BodyParser(&req); err != nil { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Invalid request body", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid request body") } - if req.ArtistID == "" { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Artist ID is required", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Artist ID is required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Artist ID is required") } downloader := strings.Clone(c.Query("downloader", "dummy")) jobID, err := h.service.DownloadArtist(downloader, req.ArtistID) if err != nil { slog.Error("Failed to start artist download", "error", err) - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to start artist download", - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to start download", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start artist download") } - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Artist download started", - }) - } - return c.JSON(fiber.Map{ - "jobId": jobID, - "message": "Download started", - }) + return respond.ToastJob(c, jobID, "Artist download started") } // DownloadTracks handles multiple track download requests @@ -517,28 +277,12 @@ func (h *Handler) DownloadTracks(c *fiber.Ctx) error { var req DownloadTracksRequest if err := c.BodyParser(&req); err != nil { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Invalid request body", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid request body") } - if req.TrackIDs == "" { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Track IDs are required", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Track IDs are required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Track IDs are required") } - // Split comma-separated track IDs trackIDs := strings.Split(req.TrackIDs, ",") for i, id := range trackIDs { trackIDs[i] = strings.TrimSpace(id) @@ -548,25 +292,10 @@ func (h *Handler) DownloadTracks(c *fiber.Ctx) error { jobID, err := h.service.DownloadTracks(downloader, trackIDs) if err != nil { slog.Error("Failed to start tracks download", "error", err) - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to start tracks download", - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to start download", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start tracks download") } - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Tracks download started", - }) - } - return c.JSON(fiber.Map{ - "jobId": jobID, - "message": "Download started", - }) + return respond.ToastJob(c, jobID, "Tracks download started") } // DownloadPlaylist handles playlist download requests @@ -578,39 +307,15 @@ func (h *Handler) DownloadPlaylist(c *fiber.Ctx) error { PlaylistName string `json:"playlistName" form:"playlistName"` } if err := c.BodyParser(&req); err != nil { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Invalid request body", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Invalid request body") } - if req.TrackIDs == "" { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Track IDs are required", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Track IDs are required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Track IDs are required") } - if req.PlaylistName == "" { - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Playlist name is required", - }) - } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Playlist name is required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Playlist name is required") } - // Split comma-separated track IDs trackIDs := strings.Split(req.TrackIDs, ",") for i, id := range trackIDs { trackIDs[i] = strings.TrimSpace(id) @@ -620,30 +325,10 @@ func (h *Handler) DownloadPlaylist(c *fiber.Ctx) error { jobID, err := h.service.DownloadPlaylist(downloader, trackIDs, req.PlaylistName) if err != nil { slog.Error("Failed to start playlist download", "error", err) - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to start playlist download", - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to start download", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start playlist download") } - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": fmt.Sprintf("Playlist '%s' download started", req.PlaylistName), - }) - } - return c.JSON(fiber.Map{ - "jobId": jobID, - "message": "Download started", - }) -} - -// GetAlbumTracksRequest represents a request to get album tracks -type GetAlbumTracksRequest struct { - AlbumID string `json:"albumId" form:"albumId"` + return respond.ToastJob(c, jobID, "Playlist '"+req.PlaylistName+"' download started") } // GetAlbumTracks handles requests to get tracks from an album @@ -652,38 +337,31 @@ func (h *Handler) GetAlbumTracks(c *fiber.Ctx) error { albumID := c.Params("albumId") if albumID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Album ID is required", - }) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Album ID is required"}) } downloader := strings.Clone(c.Query("downloader", "dummy")) tracks, err := h.service.GetAlbumTracks(downloader, albumID) if err != nil { slog.Error("Failed to get album tracks", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Failed to get album tracks", - }) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to get album tracks"}) } - // Create album object for template - album := &music.Album{ID: albumID, Title: "Album"} // This should be fetched from the service + album := &music.Album{ID: albumID, Title: "Album"} if len(tracks) > 0 && tracks[0].Album != nil { album = tracks[0].Album } - // Calculate total duration var totalDuration int for _, track := range tracks { totalDuration += track.Metadata.Duration } - // Convert []music.Track to []*music.Track for template compatibility trackPtrs := make([]*music.Track, len(tracks)) for i := range tracks { trackPtrs[i] = &tracks[i] } - return c.Render("downloading/album_tracks", fiber.Map{ + return respond.Partial(c, "downloading/album_tracks", fiber.Map{ "Album": album, "Tracks": trackPtrs, "TotalDuration": totalDuration, @@ -691,7 +369,7 @@ func (h *Handler) GetAlbumTracks(c *fiber.Ctx) error { }) } -// GetChartTracksHTMX handles HTMX requests for chart tracks +// GetChartTracks handles chart track requests func (h *Handler) GetChartTracks(c *fiber.Ctx) error { limit := 20 if limitStr := c.Query("limit"); limitStr != "" { @@ -702,20 +380,17 @@ func (h *Handler) GetChartTracks(c *fiber.Ctx) error { downloader := strings.Clone(c.Query("downloader", "dummy")) - // Get downloader capabilities var caps DownloaderCapabilities if d, exists := h.service.pluginManager.GetDownloader(downloader); exists { caps = d.Capabilities() } - // If downloader doesn't support chart tracks, show not supported message if !caps.SupportsChartTracks { - // Get the downloader name downloaderName := downloader if d, exists := h.service.pluginManager.GetDownloader(downloader); exists { downloaderName = d.Name() } - return c.Render("downloading/chart_tracks", fiber.Map{ + return respond.Partial(c, "downloading/chart_tracks", fiber.Map{ "Tracks": []*music.Track{}, "NotSupported": true, "DownloaderName": downloaderName, @@ -723,13 +398,9 @@ func (h *Handler) GetChartTracks(c *fiber.Ctx) error { }) } - // Always try to fetch tracks, even if the downloader is disabled tracks, err := h.service.GetChartTracks(downloader, limit) - - // Get status for error message context statuses := h.service.GetDownloaderStatuses() - // Get the downloader name and use it for status lookup downloaderName := downloader if d, exists := h.service.pluginManager.GetDownloader(downloader); exists { downloaderName = d.Name() @@ -738,19 +409,19 @@ func (h *Handler) GetChartTracks(c *fiber.Ctx) error { downloaderStatus := statuses[downloaderKey] if err != nil || downloaderStatus.Status != "valid" { - return c.Render("downloading/chart_tracks", fiber.Map{ + return respond.Partial(c, "downloading/chart_tracks", fiber.Map{ "Tracks": []*music.Track{}, "DownloaderStatus": downloaderStatus, "DownloaderName": downloaderName, "Downloader": downloader, }) } - // Convert []music.Track to []*music.Track for template compatibility + trackPtrs := make([]*music.Track, len(tracks)) for i := range tracks { trackPtrs[i] = &tracks[i] } - return c.Render("downloading/chart_tracks", fiber.Map{ + return respond.Partial(c, "downloading/chart_tracks", fiber.Map{ "Tracks": trackPtrs, "DownloaderStatus": downloaderStatus, "DownloaderName": downloaderName, @@ -764,37 +435,27 @@ func (h *Handler) GetUserInfo(c *fiber.Ctx) error { userInfo := h.service.GetUserInfo(downloader) statuses := h.service.GetDownloaderStatuses() - // Get the downloader name and use it for status lookup downloaderName := downloader if d, exists := h.service.pluginManager.GetDownloader(downloader); exists { downloaderName = d.Name() } downloaderKey := strings.ToLower(downloaderName) downloaderStatus := statuses[downloaderKey] - - // Check if any downloaders are available hasDownloaders := len(h.service.pluginManager.GetAllDownloaders()) > 0 - // Get downloader capabilities var caps DownloaderCapabilities if d, exists := h.service.pluginManager.GetDownloader(downloader); exists { caps = d.Capabilities() } - if c.Get("HX-Request") == "true" { - return c.Render("downloading/user_info", fiber.Map{ - "UserInfo": userInfo, - "Statuses": statuses, - "DownloaderName": downloaderName, - "DownloaderStatus": downloaderStatus, - "HasDownloaders": hasDownloaders, - "CurrentDownloader": downloader, - "Capabilities": caps, - }) - } - return c.JSON(fiber.Map{ - "userInfo": userInfo, - "statuses": statuses, + return respond.Partial(c, "downloading/user_info", fiber.Map{ + "UserInfo": userInfo, + "Statuses": statuses, + "DownloaderName": downloaderName, + "DownloaderStatus": downloaderStatus, + "HasDownloaders": hasDownloaders, + "CurrentDownloader": downloader, + "Capabilities": caps, }) } @@ -803,9 +464,7 @@ func (h *Handler) GetDownloaderCapabilities(c *fiber.Ctx) error { downloader := strings.Clone(c.Query("downloader", "dummy")) caps, err := h.service.GetDownloaderCapabilities(downloader) if err != nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ - "error": err.Error(), - }) + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(caps) } diff --git a/src/features/downloading/routes.go b/src/features/downloading/routes.go index 08aff957..293064d1 100644 --- a/src/features/downloading/routes.go +++ b/src/features/downloading/routes.go @@ -8,32 +8,19 @@ import ( func RegisterRoutes(app *fiber.App, service *Service) { handler := NewHandler(service) - // API routes for downloading - api := app.Group("/downloads") - - // Search endpoints - api.Post("/search", handler.Search) - api.Post("/search/albums", handler.SearchAlbums) - api.Post("/search/tracks", handler.SearchTracks) - - // Navigation endpoints - - api.Get("/album/:albumId/tracks", handler.GetAlbumTracks) - - // Download endpoints - api.Post("/track", handler.DownloadTrack) - api.Post("/album", handler.DownloadAlbum) - api.Post("/artist", handler.DownloadArtist) - api.Post("/tracks", handler.DownloadTracks) - api.Post("/playlist", handler.DownloadPlaylist) - - // Capabilities endpoint - api.Get("/capabilities", handler.GetDownloaderCapabilities) - - // User info endpoint - api.Get("/user/info", handler.GetUserInfo) - - ui := app.Group("/ui") - ui.Get("/download", handler.RenderDownloadSection) - ui.Get("/downloading/chart/tracks", handler.GetChartTracks) + app.Get("/downloads", handler.RenderDownloadSection) + + downloads := app.Group("/downloads") + downloads.Post("/search", handler.Search) + downloads.Post("/search/albums", handler.SearchAlbums) + downloads.Post("/search/tracks", handler.SearchTracks) + downloads.Get("/album/:albumId/tracks", handler.GetAlbumTracks) + downloads.Post("/track", handler.DownloadTrack) + downloads.Post("/album", handler.DownloadAlbum) + downloads.Post("/artist", handler.DownloadArtist) + downloads.Post("/tracks", handler.DownloadTracks) + downloads.Post("/playlist", handler.DownloadPlaylist) + downloads.Get("/capabilities", handler.GetDownloaderCapabilities) + downloads.Get("/user/info", handler.GetUserInfo) + downloads.Get("/chart/tracks", handler.GetChartTracks) } diff --git a/src/features/hosting/respond/respond.go b/src/features/hosting/respond/respond.go new file mode 100644 index 00000000..c3128e76 --- /dev/null +++ b/src/features/hosting/respond/respond.go @@ -0,0 +1,92 @@ +package respond + +import ( + "fmt" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// Section renders the section partial for HTMX requests or the full main layout otherwise. +// Assumes templates follow the "sections/
" naming convention. +// data must not be nil — a nil map causes a panic when "Section" is injected for non-HTMX requests. +func Section(c *fiber.Ctx, section string, data fiber.Map) error { + if c.Get("HX-Request") != "true" { + data["Section"] = section + return c.Render("main", data) + } + return c.Render("sections/"+section, data) +} + +// ToastErr responds with an error toast for HTMX requests or a JSON error body otherwise. +func ToastErr(c *fiber.Ctx, status int, msg string) error { + if c.Get("HX-Request") == "true" { + return c.Status(status).Render("toast/toastErr", fiber.Map{"Msg": msg}) + } + return c.Status(status).JSON(fiber.Map{"error": msg}) +} + +// ToastOk responds with a success toast for HTMX requests or a JSON message otherwise. +func ToastOk(c *fiber.Ctx, msg string) error { + if c.Get("HX-Request") == "true" { + return c.Render("toast/toastOk", fiber.Map{"Msg": msg}) + } + return c.JSON(fiber.Map{"message": msg}) +} + +// ToastJob responds with a success toast for HTMX requests or a 202 JSON body with the job_id otherwise. +func ToastJob(c *fiber.Ctx, jobID string, msg string) error { + if c.Get("HX-Request") == "true" { + return c.Render("toast/toastOk", fiber.Map{"Msg": msg}) + } + return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"job_id": jobID}) +} + +// Partial renders the given template for HTMX requests or returns the same data as JSON otherwise. +func Partial(c *fiber.Ctx, template string, data fiber.Map) error { + if c.Get("HX-Request") == "true" { + return c.Render(template, data) + } + return c.JSON(data) +} + +// Text sends a plain string for HTMX requests or {"key": key, "value": value} JSON otherwise. +// An optional htmxText overrides the formatted string sent to HTMX; without it, value is stringified. +func Text(c *fiber.Ctx, key string, value any, htmxText ...string) error { + if c.Get("HX-Request") == "true" { + if len(htmxText) > 0 { + return c.SendString(htmxText[0]) + } + return c.SendString(fmt.Sprint(value)) + } + return c.JSON(fiber.Map{"key": key, "value": value}) +} + +// Resource serves binary or file content by default, or returns {"type": mimeType, "url": url} JSON +// when the client sends Accept: application/json. +// Unlike other helpers, this uses Accept-header negotiation rather than HX-Request, because browser +// resource tags (, ) never send HX-Request but still need the binary response. +func Resource(c *fiber.Ctx, mimeType, url string, serve func() error) error { + if strings.Contains(c.Get("Accept"), "application/json") { + return c.JSON(fiber.Map{"type": mimeType, "url": url}) + } + return serve() +} + +// HTMX renders the given template only for HTMX requests. +// Non-HTMX clients receive 406 Not Acceptable with a JSON error body. +// +// Use this for endpoints that are intrinsically tied to the HTMX interaction +// model and have no meaningful alternative representation — for example, +// endpoints that return raw HTML fragments whose rendering logic depends on +// in-flight HTMX state (polling, event triggers, multi-step UI flows). +// Do NOT use it just because a JSON response is inconvenient; prefer Partial +// if the data can stand alone as JSON. +func HTMX(c *fiber.Ctx, template string, data fiber.Map) error { + if c.Get("HX-Request") != "true" { + return c.Status(fiber.StatusNotAcceptable).JSON(fiber.Map{ + "error": "this endpoint is only available via HTMX", + }) + } + return c.Render(template, data) +} diff --git a/src/features/hosting/server.go b/src/features/hosting/server.go index 2ced7666..b421a54c 100644 --- a/src/features/hosting/server.go +++ b/src/features/hosting/server.go @@ -3,7 +3,9 @@ package hosting import ( "fmt" "log/slog" + "net/url" "os" + "path/filepath" "strings" "github.com/contre95/soulsolid/src/features/config" @@ -16,6 +18,7 @@ import ( "github.com/contre95/soulsolid/src/features/metrics" "github.com/contre95/soulsolid/src/features/playlists" "github.com/contre95/soulsolid/src/features/reorganize" + "github.com/contre95/soulsolid/src/features/streaming" "github.com/contre95/soulsolid/src/features/ui" "github.com/contre95/soulsolid/src/music" "github.com/gofiber/fiber/v2" @@ -29,7 +32,7 @@ type Server struct { } // NewServer creates a new HTTP server. -func NewServer(cfg *config.Manager, importingService *importing.Service, libraryService *library.Service, playlistsService *playlists.Service, downloadingService *downloading.Service, jobService *jobs.Service, tagService *metadata.Service, lyricsService *lyrics.Service, metricsService *metrics.Service, reorganizeService *reorganize.Service) *Server { +func NewServer(cfg *config.Manager, importingService *importing.Service, libraryService *library.Service, playlistsService *playlists.Service, downloadingService *downloading.Service, jobService *jobs.Service, tagService *metadata.Service, lyricsService *lyrics.Service, metricsService *metrics.Service, reorganizeService *reorganize.Service, streamingService *streaming.Service) *Server { engine := html.New("./views", ".html") engine.Debug(cfg.Get().Logger.Level == "debug") // Add custom template functions @@ -78,6 +81,8 @@ func NewServer(cfg *config.Manager, importingService *importing.Service, library engine.AddFunc("capitalize", func(s string) string { return strings.Title(strings.ToLower(s)) }) + engine.AddFunc("pathBase", filepath.Base) + engine.AddFunc("urlEncode", url.QueryEscape) app := fiber.New(fiber.Config{ Views: engine, @@ -135,6 +140,7 @@ func NewServer(cfg *config.Manager, importingService *importing.Service, library lyrics.RegisterRoutes(app, lyricsHandler) reorganizeHandler := reorganize.NewHandler(reorganizeService, cfg) reorganize.RegisterRoutes(app, reorganizeHandler) + streaming.RegisterRoutes(app, streamingService) return &Server{app: app, port: cfg.Get().Server.Port} } diff --git a/src/features/importing/handlers.go b/src/features/importing/handlers.go index f1001927..133616b9 100644 --- a/src/features/importing/handlers.go +++ b/src/features/importing/handlers.go @@ -8,6 +8,7 @@ import ( "sort" "time" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/contre95/soulsolid/src/music" "github.com/gofiber/fiber/v2" ) @@ -57,14 +58,7 @@ func NewHandler(service *Service) *Handler { // RenderImportSection renders the import page. func (h *Handler) RenderImportSection(c *fiber.Ctx) error { slog.Debug("RenderImport handler called") - data := fiber.Map{ - "Title": "Import", - } - if c.Get("HX-Request") != "true" { - data["Section"] = "import" - return c.Render("main", data) - } - return c.Render("sections/import", data) + return respond.Section(c, "import", fiber.Map{"Title": "Import"}) } // ImportDirectory is the handler for importing a directory. @@ -74,24 +68,16 @@ func (h *Handler) ImportDirectory(c *fiber.Ctx) error { } var req ImportPathRequest if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "cannot parse request body", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Cannot parse request body") } jobID, err := h.service.ImportDirectory(c.Context(), req.DirectoryPath) if err != nil { slog.Error("Error importing directory", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to start sync job", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start sync job") } slog.Info("ImportDirectory: directory import started", "jobID", jobID) - c.Response().Header.Set("HX-Trigger", "jobStarted") - c.Response().Header.Set("HX-Trigger", "queueUpdated") - c.Response().Header.Set("HX-Trigger", "refreshImportQueueBadge") - return c.Render("toast/toastInfo", fiber.Map{ - "Msg": "Directory import started!", - }) + c.Response().Header.Set("HX-Trigger", "jobStarted,queueUpdated,refreshImportQueueBadge") + return respond.ToastJob(c, jobID, "Directory import started!") } // ProcessQueueItem handles import/cancel actions for individual queue items @@ -101,11 +87,8 @@ func (h *Handler) ProcessQueueItem(c *fiber.Ctx) error { err := h.service.ProcessQueueItem(c.Context(), itemID, action) if err != nil { slog.Error("Failed to process queue item", "error", err, "itemID", itemID, "action", action) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": fmt.Sprintf("Failed to process queue item: %s", err.Error()), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to process queue item") } - // Return success response that updates the UI actionMsg := "skipped" switch action { case "import": @@ -115,21 +98,18 @@ func (h *Handler) ProcessQueueItem(c *fiber.Ctx) error { case "delete": actionMsg = "deleted" } - c.Response().Header.Set("HX-Trigger", "queueUpdated") - c.Response().Header.Set("HX-Trigger", "refreshImportQueueBadge") - c.Response().Header.Set("HX-Trigger", "activateIndividualGrouping") - return c.Render("toast/toastOk", fiber.Map{ - "Msg": fmt.Sprintf("Track %s successfully", actionMsg), - }) + c.Response().Header.Set("HX-Trigger", "queueUpdated,refreshImportQueueBadge,activateIndividualGrouping") + return respond.ToastOk(c, fmt.Sprintf("Track %s successfully", actionMsg)) } // QueueCount returns the current queue count formatted as "(X)" or empty if 0 func (h *Handler) QueueCount(c *fiber.Ctx) error { count := len(h.service.GetQueuedItems()) - if count == 0 { - return c.SendString("") + formatted := "" + if count > 0 { + formatted = fmt.Sprintf("(%d)", count) } - return c.SendString(fmt.Sprintf("(%d)", count)) + return respond.Text(c, "queue_count", count, formatted) } // ClearQueue handles clearing all items from the import queue @@ -137,16 +117,10 @@ func (h *Handler) ClearQueue(c *fiber.Ctx) error { err := h.service.ClearQueue() if err != nil { slog.Error("Failed to clear queue", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to clear queue", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to clear queue") } - c.Response().Header.Set("HX-Trigger", "queueUpdated") - c.Response().Header.Set("HX-Trigger", "refreshImportQueueBadge") - c.Response().Header.Set("HX-Trigger", "activateIndividualGrouping") - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Queue cleared successfully", - }) + c.Response().Header.Set("HX-Trigger", "queueUpdated,refreshImportQueueBadge,activateIndividualGrouping") + return respond.ToastOk(c, "Queue cleared successfully") } // PruneDownloadPath handles pruning the download path and clearing the queue @@ -154,25 +128,17 @@ func (h *Handler) PruneDownloadPath(c *fiber.Ctx) error { err := h.service.PruneDownloadPath(c.Context()) if err != nil { slog.Error("Failed to prune download path", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to prune download path", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to prune download path") } - c.Response().Header.Set("HX-Trigger", "queueUpdated") - c.Response().Header.Set("HX-Trigger", "refreshImportQueueBadge") - c.Response().Header.Set("HX-Trigger", "activateIndividualGrouping") - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Download path pruned and queue cleared successfully", - }) + c.Response().Header.Set("HX-Trigger", "queueUpdated,refreshImportQueueBadge,activateIndividualGrouping") + return respond.ToastOk(c, "Download path pruned and queue cleared successfully") } // ToggleWatcher toggles the file system watcher on/off func (h *Handler) ToggleWatcher(c *fiber.Ctx) error { action := c.FormValue("action") if action == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "action parameter required", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "action parameter required") } var err error @@ -186,28 +152,22 @@ func (h *Handler) ToggleWatcher(c *fiber.Ctx) error { err = h.service.StopWatcher() msg = "File watcher stopped successfully" default: - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "invalid action", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "invalid action") } if err != nil { slog.Error("Failed to toggle watcher", "action", action, "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to " + action + " file watcher", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to "+action+" file watcher") } c.Response().Header.Set("HX-Trigger", "watcherStatusChanged") - return c.Render("toast/toastOk", fiber.Map{ - "Msg": msg, - }) + return respond.ToastOk(c, msg) } // GetWatcherStatus returns the current status of the watcher func (h *Handler) GetWatcherStatus(c *fiber.Ctx) error { running := h.service.GetWatcherStatus() - return c.Render("components/status_badge", fiber.Map{ + return respond.Partial(c, "components/status_badge", fiber.Map{ "Running": running, }) } @@ -215,7 +175,7 @@ func (h *Handler) GetWatcherStatus(c *fiber.Ctx) error { // GetWatcherToggleState returns the toggle input element with correct checked state func (h *Handler) GetWatcherToggleState(c *fiber.Ctx) error { running := h.service.GetWatcherStatus() - return c.Render("components/toggle", fiber.Map{ + return respond.Partial(c, "components/toggle", fiber.Map{ "ID": "watcher-toggle", "Checked": running, "PostURL": "/import/watcher/toggle", @@ -231,7 +191,7 @@ func (h *Handler) GetDirectoryForm(c *fiber.Ctx) error { // Get default download path from service defaultDownloadPath := h.service.config.Get().DownloadPath - return c.Render("importing/directory_form", fiber.Map{ + return respond.Partial(c, "importing/directory_form", fiber.Map{ "DefaultDownloadPath": defaultDownloadPath, "Config": h.service.config.Get(), }) @@ -265,7 +225,7 @@ func (h *Handler) RenderQueueItems(c *fiber.Ctx) error { queueItems = queueItems[:10] } - return c.Render("importing/queue_items", fiber.Map{ + return respond.Partial(c, "importing/queue_items", fiber.Map{ "QueueItems": queueItems, }) } @@ -278,7 +238,7 @@ func (h *Handler) GetQueueHeader(c *fiber.Ctx) error { queueItems := h.service.GetQueuedItems() queueCount := len(queueItems) - return c.Render("importing/queue_header", fiber.Map{ + return respond.Partial(c, "importing/queue_header", fiber.Map{ "QueueCount": queueCount, }) } @@ -298,23 +258,17 @@ func (h *Handler) ProcessQueueGroup(c *fiber.Ctx) error { } if groupType != "artist" && groupType != "album" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "groupType must be 'artist' or 'album'", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "groupType must be 'artist' or 'album'") } if action != "import" && action != "cancel" && action != "delete" && action != "replace" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "action must be one of: import, cancel, delete, replace", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "action must be one of: import, cancel, delete, replace") } err = h.service.ProcessQueueGroup(c.Context(), decodedGroupKey, groupType, action) if err != nil { slog.Error("Failed to process group", "error", err, "groupKey", decodedGroupKey, "groupType", groupType, "action", action) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": fmt.Sprintf("Failed to process group %s", decodedGroupKey), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to process group %s", decodedGroupKey)) } actionMsg := "processed" @@ -329,19 +283,14 @@ func (h *Handler) ProcessQueueGroup(c *fiber.Ctx) error { actionMsg = "deleted" } - c.Response().Header.Set("HX-Trigger", "queueUpdated") - c.Response().Header.Set("HX-Trigger", "refreshImportQueueBadge") - - // Send grouping activation header based on group type + trigger := "queueUpdated,refreshImportQueueBadge" if groupType == "artist" { - c.Response().Header.Set("HX-Trigger", "activateArtistGrouping") - } else if groupType == "album" { - c.Response().Header.Set("HX-Trigger", "activateAlbumGrouping") + trigger += ",activateArtistGrouping" + } else { + trigger += ",activateAlbumGrouping" } - - return c.Render("toast/toastOk", fiber.Map{ - "Msg": fmt.Sprintf("Group '%s' %s successfully", decodedGroupKey, actionMsg), - }) + c.Response().Header.Set("HX-Trigger", trigger) + return respond.ToastOk(c, fmt.Sprintf("Group '%s' %s successfully", decodedGroupKey, actionMsg)) } // RenderGroupedQueueItems renders queue items grouped by artist or album @@ -389,7 +338,7 @@ func (h *Handler) RenderGroupedQueueItems(c *fiber.Ctx) error { } } - return c.Render(templateName, fiber.Map{ + return respond.Partial(c, templateName, fiber.Map{ "Groups": viewGroups, "GroupType": groupType, }) diff --git a/src/features/importing/routes.go b/src/features/importing/routes.go index affe7902..2e914bb7 100644 --- a/src/features/importing/routes.go +++ b/src/features/importing/routes.go @@ -8,26 +8,21 @@ import ( func RegisterRoutes(app *fiber.App, service *Service) { handler := NewHandler(service) - ui := app.Group("/ui") - // UI endpoints - ui.Get("/import", handler.RenderImportSection) - ui.Get("/importing/directory/form", handler.GetDirectoryForm) - ui.Get("/importing/queue/items", handler.RenderQueueItems) - ui.Get("/importing/queue/items/grouped", handler.RenderGroupedQueueItems) - ui.Get("/importing/queue/header", handler.GetQueueHeader) - - // Action endpoints - app.Get("/import/queue/:id/artwork", handler.ServeQueueItemArtwork) - app.Post("/import/directory", handler.ImportDirectory) - app.Post("/import/queue/:id/:action", handler.ProcessQueueItem) - app.Post("/import/queue/group/:groupType/:groupKey/:action", handler.ProcessQueueGroup) - app.Post("/import/queue/clear", handler.ClearQueue) - app.Post("/import/prune/download-path", handler.PruneDownloadPath) - app.Get("/import/queue/count", handler.QueueCount) - - // Watcher endpoints - app.Post("/import/watcher/toggle", handler.ToggleWatcher) - app.Get("/import/watcher/status", handler.GetWatcherStatus) - app.Get("/import/watcher/toggle-state", handler.GetWatcherToggleState) + app.Get("/import", handler.RenderImportSection) + importGroup := app.Group("/import") + importGroup.Get("/directory/form", handler.GetDirectoryForm) + importGroup.Get("/queue/items", handler.RenderQueueItems) + importGroup.Get("/queue/items/grouped", handler.RenderGroupedQueueItems) + importGroup.Get("/queue/header", handler.GetQueueHeader) + importGroup.Get("/queue/:id/artwork", handler.ServeQueueItemArtwork) + importGroup.Post("/directory", handler.ImportDirectory) + importGroup.Post("/queue/:id/:action", handler.ProcessQueueItem) + importGroup.Post("/queue/group/:groupType/:groupKey/:action", handler.ProcessQueueGroup) + importGroup.Post("/queue/clear", handler.ClearQueue) + importGroup.Post("/prune/download-path", handler.PruneDownloadPath) + importGroup.Get("/queue/count", handler.QueueCount) + importGroup.Post("/watcher/toggle", handler.ToggleWatcher) + importGroup.Get("/watcher/status", handler.GetWatcherStatus) + importGroup.Get("/watcher/toggle-state", handler.GetWatcherToggleState) } diff --git a/src/features/importing/service.go b/src/features/importing/service.go index ac4f434b..f3f4dbf9 100644 --- a/src/features/importing/service.go +++ b/src/features/importing/service.go @@ -78,6 +78,18 @@ func (s *Service) GetQueuedItems() map[string]music.QueueItem { return s.queue.GetAll() } +// GetPendingTrackPath returns the file path of a pending track in the import queue. +func (s *Service) GetPendingTrackPath(itemID string) (string, error) { + item, err := s.queue.GetByID(itemID) + if err != nil { + return "", fmt.Errorf("queue item not found: %w", err) + } + if item.Track == nil { + return "", fmt.Errorf("queue item has no track") + } + return item.Track.Path, nil +} + // GetPendingTrackArtwork returns the embedded artwork for a pending (not yet imported) track file. func (s *Service) GetPendingTrackArtwork(itemID string) ([]byte, string, error) { item, err := s.queue.GetByID(itemID) diff --git a/src/features/jobs/handlers.go b/src/features/jobs/handlers.go index b9e5e078..9dd3c8b1 100644 --- a/src/features/jobs/handlers.go +++ b/src/features/jobs/handlers.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/contre95/soulsolid/src/music" "github.com/gofiber/fiber/v2" ) @@ -27,14 +28,7 @@ func NewHandler(service *Service) *Handler { // RenderJobsSection renders the jobs page. func (h *Handler) RenderJobsSection(c *fiber.Ctx) error { - data := fiber.Map{ - "Title": "Jobs", - } - if c.Get("HX-Request") != "true" { - data["Section"] = "jobs" - return c.Render("main", data) - } - return c.Render("sections/jobs", data) + return respond.Section(c, "jobs", fiber.Map{"Title": "Jobs"}) } func (h *Handler) HandleStartJob(c *fiber.Ctx) error { @@ -43,23 +37,11 @@ func (h *Handler) HandleStartJob(c *fiber.Ctx) error { jobID, err := h.service.StartJob(jobType, name, nil) if err != nil { - // Check if this is an HTMX request - if c.Get("HX-Request") == "true" { - return c.Status(500).SendString(fmt.Sprintf("Failed to start job: %s", err.Error())) - } - return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + return respond.ToastErr(c, 500, fmt.Sprintf("Failed to start job: %s", err.Error())) } - // Trigger HTMX to refresh the badge immediately c.Set("HX-Trigger", "refreshActiveJobsBadge") - - // Check if this is an HTMX request - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": fmt.Sprintf("Started %s job", jobType), - }) - } - return c.JSON(fiber.Map{"job_id": jobID}) + return respond.ToastJob(c, jobID, fmt.Sprintf("Started %s job", jobType)) } func (h *Handler) HandleJobStatus(c *fiber.Ctx) error { @@ -135,7 +117,7 @@ func (h *Handler) HandleJobProgress(c *fiber.Ctx) error { c.Set("HX-Trigger", "done") } - return c.Render("jobs/job_card_progress_bar", fiber.Map{ + return respond.Partial(c, "jobs/job_card_progress_bar", fiber.Map{ "ID": job.ID, "Name": job.Name, "Type": job.Type, @@ -179,7 +161,7 @@ func (h *Handler) HandleCancelJob(c *fiber.Ctx) error { return c.Status(404).SendString("Job not found") } - return c.Render("jobs/job_card", fiber.Map{ + return respond.Partial(c, "jobs/job_card", fiber.Map{ "ID": job.ID, "Name": job.Name, "Type": job.Type, @@ -194,23 +176,15 @@ func (h *Handler) HandleCancelJob(c *fiber.Ctx) error { func (h *Handler) HandleCleanupJobs(c *fiber.Ctx) error { h.service.CleanupOldJobs(24 * time.Hour) - return c.JSON(fiber.Map{"status": "cleanup completed"}) + return respond.ToastOk(c, "cleanup completed") } func (h *Handler) HandleClearFinishedJobs(c *fiber.Ctx) error { - err := h.service.ClearFinishedJobs() - if err != nil { - return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + if err := h.service.ClearFinishedJobs(); err != nil { + return respond.ToastErr(c, 500, err.Error()) } - - if c.Get("HX-Request") == "true" { - c.Set("HX-Trigger", "refreshJobList") - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Finished jobs cleared", - }) - } - - return c.JSON(fiber.Map{"status": "finished jobs cleared"}) + c.Set("HX-Trigger", "refreshJobList") + return respond.ToastOk(c, "Finished jobs cleared") } func (h *Handler) HandleActiveJob(c *fiber.Ctx) error { @@ -229,7 +203,7 @@ func (h *Handler) HandleActiveJob(c *fiber.Ctx) error { return activeJobs[i].CreatedAt.After(activeJobs[j].CreatedAt) }) - return c.Render("jobs/active_list", fiber.Map{ + return respond.Partial(c, "jobs/active_list", fiber.Map{ "Jobs": activeJobs, }) } @@ -254,7 +228,7 @@ func (h *Handler) HandleFilteredJobsList(c *fiber.Ctx) error { return jobs[i].CreatedAt.After(jobs[j].CreatedAt) }) - return c.Render("jobs/job_list", fiber.Map{ + return respond.Partial(c, "jobs/job_list", fiber.Map{ "Jobs": jobs, }) } @@ -267,7 +241,7 @@ func (h *Handler) HandleLatestJobs(c *fiber.Ctx) error { if len(jobs) > 5 { jobs = jobs[:5] } - return c.Render("cards/latest_jobs", fiber.Map{ + return respond.Partial(c, "cards/latest_jobs", fiber.Map{ "Jobs": jobs, }) } @@ -285,8 +259,9 @@ func (h *Handler) HandleJobsCount(c *fiber.Ctx) error { } } - if count == 0 { - return c.SendString("") + formatted := "" + if count > 0 { + formatted = fmt.Sprintf("(%d)", count) } - return c.SendString(fmt.Sprintf("(%d)", count)) + return respond.Text(c, "jobs_count", count, formatted) } diff --git a/src/features/jobs/routes.go b/src/features/jobs/routes.go index f96fb215..409e32ba 100644 --- a/src/features/jobs/routes.go +++ b/src/features/jobs/routes.go @@ -4,18 +4,16 @@ import "github.com/gofiber/fiber/v2" func RegisterRoutes(app *fiber.App, service *Service) { handler := NewHandler(service) - jobs := app.Group("/jobs") - ui := app.Group("/ui") - ui.Get("/jobs", handler.RenderJobsSection) - uiJobs := app.Group("/ui/jobs") - uiJobs.Get("/active", handler.HandleActiveJob) - uiJobs.Get("/list", handler.HandleFilteredJobsList) - uiJobs.Get("/latest", handler.HandleLatestJobs) - uiJobs.Post("/clear-finished", handler.HandleClearFinishedJobs) - uiJobs.Get("/count", handler.HandleJobsCount) - // ui.Post("/cleanup", handler.HandleCleanupJobs) - jobs.Get("/", handler.HandleJobList) + app.Get("/jobs", handler.RenderJobsSection) + + jobs := app.Group("/jobs") + jobs.Get("/active", handler.HandleActiveJob) + jobs.Get("/list", handler.HandleFilteredJobsList) + jobs.Get("/latest", handler.HandleLatestJobs) + jobs.Post("/clear-finished", handler.HandleClearFinishedJobs) + jobs.Get("/count", handler.HandleJobsCount) + jobs.Get("/all", handler.HandleJobList) jobs.Post("/start/:type", handler.HandleStartJob) jobs.Get("/:id", handler.HandleJobStatus) jobs.Get("/:id/progress", handler.HandleJobProgress) diff --git a/src/features/library/handlers.go b/src/features/library/handlers.go index bfbc45b1..7d14e9ce 100644 --- a/src/features/library/handlers.go +++ b/src/features/library/handlers.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/contre95/soulsolid/src/music" "github.com/gofiber/fiber/v2" ) @@ -51,17 +52,12 @@ func (h *Handler) RenderLibrarySection(c *fiber.Ctx) error { albums = []*music.Album{} // Continue with empty list } - data := fiber.Map{ + return respond.Section(c, "library", fiber.Map{ "Title": "Library", "DefaultDownloadPath": h.service.configManager.Get().DownloadPath, "SearchArtists": artists, "SearchAlbums": albums, - } - if c.Get("HX-Request") != "true" { - data["Section"] = "library" - return c.Render("main", data) - } - return c.Render("sections/library", data) + }) } // Pagination represents pagination information @@ -139,9 +135,9 @@ func (h *Handler) GetArtistsCount(c *fiber.Ctx) error { artists, err := h.service.GetArtists(c.Context()) if err != nil { slog.Error("Error loading artists count", "error", err) - return c.Status(fiber.StatusInternalServerError).SendString("Error loading artists count") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Error loading artists count") } - return c.SendString(fmt.Sprintf("%d", len(artists))) + return respond.Text(c, "artists_count", len(artists)) } // GetAlbumsCount returns the count of albums in the library. @@ -150,9 +146,9 @@ func (h *Handler) GetAlbumsCount(c *fiber.Ctx) error { albums, err := h.service.GetAlbums(c.Context()) if err != nil { slog.Error("Error loading albums count", "error", err) - return c.Status(fiber.StatusInternalServerError).SendString("Error loading albums count") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Error loading albums count") } - return c.SendString(fmt.Sprintf("%d", len(albums))) + return respond.Text(c, "albums_count", len(albums)) } // GetTracksCount returns the count of tracks in the library. @@ -161,9 +157,9 @@ func (h *Handler) GetTracksCount(c *fiber.Ctx) error { count, err := h.service.GetTracksCount(c.Context()) if err != nil { slog.Error("Error loading tracks count", "error", err) - return c.Status(fiber.StatusInternalServerError).SendString("Error loading tracks count") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Error loading tracks count") } - return c.SendString(fmt.Sprintf("%d tracks", count)) + return respond.Text(c, "tracks_count", count, fmt.Sprintf("%d tracks", count)) } // GetStorageSize returns the storage size of the library. @@ -172,10 +168,9 @@ func (h *Handler) GetStorageSize(c *fiber.Ctx) error { size, err := h.service.GetStorageSize(c.Context()) if err != nil { slog.Error("Error loading storage size", "error", err) - return c.Status(fiber.StatusInternalServerError).SendString("Error loading storage size") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Error loading storage size") } - // Format the size var formatted string if size >= 1_000_000_000_000 { formatted = fmt.Sprintf("%.1f TB", float64(size)/math.Pow(10, 12)) @@ -188,8 +183,7 @@ func (h *Handler) GetStorageSize(c *fiber.Ctx) error { } else { formatted = fmt.Sprintf("%d B", size) } - - return c.SendString(formatted) + return respond.Text(c, "storage_size_bytes", size, formatted) } // GetLibraryTable renders the library table section with tabs. @@ -215,7 +209,7 @@ func (h *Handler) GetLibraryTable(c *fiber.Ctx) error { genres = []string{} } - return c.Render("library/library_table", fiber.Map{ + return respond.Partial(c, "library/library_table", fiber.Map{ "SearchArtists": artists, "SearchAlbums": albums, "Genres": genres, @@ -455,25 +449,10 @@ func (h *Handler) GetUnifiedSearch(c *fiber.Ctx) error { pagination := NewPagination(page, limit, totalCount) - // Check if the request accepts HTML (like an HTMX request) - acceptHeader := c.Get("Accept") - hxRequest := c.Get("HX-Request") - if strings.Contains(acceptHeader, "text/html") || hxRequest == "true" { - return c.Render("library/unified_search_list", fiber.Map{ - "Results": results, - "Pagination": pagination, - "Query": query, - }) - } - - return c.JSON(fiber.Map{ - "results": results, - "pagination": fiber.Map{ - "page": page, - "limit": limit, - "totalCount": totalCount, - "totalPages": (totalCount + limit - 1) / limit, - }, + return respond.Partial(c, "library/unified_search_list", fiber.Map{ + "Results": results, + "Pagination": pagination, + "Query": query, }) } @@ -491,9 +470,9 @@ func (h *Handler) GetLibraryFileTree(c *fiber.Ctx) error { } if err != nil { slog.Error("Error getting library file tree", "error", err) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to get library file tree") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to get library file tree") } - return c.SendString(tree) + return respond.Text(c, "file_tree", tree) } // DeleteTrack deletes a track from the library. @@ -502,17 +481,13 @@ func (h *Handler) DeleteTrack(c *fiber.Ctx) error { trackID := c.Params("trackId") if trackID == "" { - return c.Status(fiber.StatusBadRequest).SendString("Track ID is required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Track ID is required") } - - err := h.service.DeleteTrack(c.Context(), trackID) - if err != nil { + if err := h.service.DeleteTrack(c.Context(), trackID); err != nil { slog.Error("Failed to delete track", "error", err, "trackId", trackID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to delete track") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to delete track") } - - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Track deleted successfully"}) + return respond.ToastOk(c, "Track deleted successfully") } // DeleteAlbum deletes an album from the library. @@ -521,17 +496,13 @@ func (h *Handler) DeleteAlbum(c *fiber.Ctx) error { albumID := c.Params("albumId") if albumID == "" { - return c.Status(fiber.StatusBadRequest).SendString("Album ID is required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Album ID is required") } - - err := h.service.DeleteAlbum(c.Context(), albumID) - if err != nil { + if err := h.service.DeleteAlbum(c.Context(), albumID); err != nil { slog.Error("Failed to delete album", "error", err, "albumId", albumID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to delete album") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to delete album") } - - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Album deleted successfully"}) + return respond.ToastOk(c, "Album deleted successfully") } // DeleteArtist deletes an artist from the library. @@ -540,17 +511,13 @@ func (h *Handler) DeleteArtist(c *fiber.Ctx) error { artistID := c.Params("artistId") if artistID == "" { - return c.Status(fiber.StatusBadRequest).SendString("Artist ID is required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Artist ID is required") } - - err := h.service.DeleteArtist(c.Context(), artistID) - if err != nil { + if err := h.service.DeleteArtist(c.Context(), artistID); err != nil { slog.Error("Failed to delete artist", "error", err, "artistId", artistID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to delete artist") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to delete artist") } - - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Artist deleted successfully"}) + return respond.ToastOk(c, "Artist deleted successfully") } // RenderTrackOverviewPanel renders the floating track overview panel. @@ -588,118 +555,9 @@ func (h *Handler) RenderTrackOverviewPanel(c *fiber.Ctx) error { lyricsPreview = strings.Join(lines, "\n") } - return c.Render("library/track_overview_panel", fiber.Map{ + return respond.Partial(c, "library/track_overview_panel", fiber.Map{ "Track": track, "Artists": artistNames.String(), "LyricsPreview": lyricsPreview, }) } - -// RenderTagEditForm renders the tag edit form for a track -func (h *Handler) RenderTagEditForm(c *fiber.Ctx) error { - slog.Debug("RenderTagEditForm handler called", "trackId", c.Params("trackId")) - - trackID := c.Params("trackId") - if trackID == "" { - return c.Status(fiber.StatusBadRequest).SendString("Track ID is required") - } - - // Get track data for editing - track, err := h.service.GetTrack(c.Context(), trackID) - if err != nil || track == nil { - slog.Error("Failed to get track for editing", "error", err, "trackId", trackID) - return c.Status(fiber.StatusNotFound).SendString("Track not found") - } - - // Fetch all artists and albums for dropdowns - artists, err := h.service.GetArtists(c.Context()) - if err != nil { - slog.Error("Failed to get artists for dropdown", "error", err) - artists = []*music.Artist{} // Continue with empty list - } - - albums, err := h.service.GetAlbums(c.Context()) - if err != nil { - slog.Error("Failed to get albums for dropdown", "error", err) - albums = []*music.Album{} // Continue with empty list - } - - // Ensure track's artists are included in the dropdown, even if missing from main query - artistMap := make(map[string]bool) - for _, artist := range artists { - artistMap[artist.ID] = true - } - // Add track artists (include those without IDs for fetched data) - for _, artistRole := range track.Artists { - if artistRole.Artist != nil { - artistID := artistRole.Artist.ID - if artistID == "" { - // Generate a temporary ID for artists without database IDs (for dropdown display) - artistID = "temp_" + artistRole.Artist.Name - artistRole.Artist.ID = artistID - } - if !artistMap[artistID] { - artists = append(artists, artistRole.Artist) - artistMap[artistID] = true - } - } - } - // Add album artists (include those without IDs for fetched data) - if track.Album != nil { - for _, artistRole := range track.Album.Artists { - if artistRole.Artist != nil { - artistID := artistRole.Artist.ID - if artistID == "" { - // Generate a temporary ID for artists without database IDs (for dropdown display) - artistID = "temp_" + artistRole.Artist.Name - artistRole.Artist.ID = artistID - } - if !artistMap[artistID] { - artists = append(artists, artistRole.Artist) - artistMap[artistID] = true - } - } - } - } - - // Ensure track has valid ID for template - if track.ID == "" { - track.ID = trackID - } - - // Determine selected album artist ID for template - selectedAlbumArtistID := "" - if track.Album != nil && len(track.Album.Artists) > 0 { - selectedAlbumArtistID = track.Album.Artists[0].Artist.ID - } - - // Create map of selected artist IDs for template - selectedArtistIDs := make(map[string]bool) - for _, artistRole := range track.Artists { - if artistRole.Artist != nil && artistRole.Artist.ID != "" { - selectedArtistIDs[artistRole.Artist.ID] = true - } - } - - // Check if request is HTMX or full page - if c.Get("HX-Request") == "true" { - // Return the full tag section with button loading HTMX for HTMX requests - return c.Render("sections/tag", fiber.Map{ - "Track": track, - "Artists": artists, - "Albums": albums, - "SelectedAlbumArtistID": selectedAlbumArtistID, - "SelectedArtistIDs": selectedArtistIDs, - }) - } - - // Return full page for direct navigation - return c.Render("main", fiber.Map{ - "Track": track, - "IsTagEdit": true, - "Artists": artists, - "Albums": albums, - "SelectedAlbumArtistID": selectedAlbumArtistID, - "SelectedArtistIDs": selectedArtistIDs, - }) -} diff --git a/src/features/library/routes.go b/src/features/library/routes.go index b7b4c90b..e60d01f5 100644 --- a/src/features/library/routes.go +++ b/src/features/library/routes.go @@ -8,13 +8,11 @@ import ( func RegisterRoutes(app *fiber.App, service *Service) { handler := NewHandler(service) - ui := app.Group("/ui") - ui.Get("/library", handler.RenderLibrarySection) - ui.Get("/library/table", handler.GetLibraryTable) - ui.Get("/library/tag/edit/:trackId", handler.RenderTagEditForm) - ui.Get("/library/tracks/:trackId/overview", handler.RenderTrackOverviewPanel) + app.Get("/library", handler.RenderLibrarySection) library := app.Group("/library") + library.Get("/table", handler.GetLibraryTable) + library.Get("/tracks/:trackId/overview", handler.RenderTrackOverviewPanel) library.Get("/search", handler.GetUnifiedSearch) library.Get("/artists/count", handler.GetArtistsCount) library.Get("/albums/count", handler.GetAlbumsCount) diff --git a/src/features/library/service.go b/src/features/library/service.go index c78baf69..91dd5f03 100644 --- a/src/features/library/service.go +++ b/src/features/library/service.go @@ -392,6 +392,18 @@ func (s *Service) GetTrack(ctx context.Context, id string) (*library.Track, erro return track, nil } +// GetLibraryTrackPath returns the file path of an imported library track. +func (s *Service) GetLibraryTrackPath(ctx context.Context, trackID string) (string, error) { + track, err := s.library.GetTrack(ctx, trackID) + if err != nil { + return "", fmt.Errorf("track not found: %w", err) + } + if track == nil { + return "", fmt.Errorf("track not found") + } + return track.Path, nil +} + // GetArtistByName finds an artist by name without creating it. func (s *Service) GetArtistByName(ctx context.Context, artistName string) (*library.Artist, error) { artist, err := s.library.GetArtistByName(ctx, artistName) diff --git a/src/features/lyrics/handlers.go b/src/features/lyrics/handlers.go index c17daddc..a9f064b3 100644 --- a/src/features/lyrics/handlers.go +++ b/src/features/lyrics/handlers.go @@ -9,6 +9,7 @@ import ( "sort" "time" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/contre95/soulsolid/src/music" "github.com/gofiber/fiber/v2" ) @@ -65,49 +66,55 @@ func convertQueueItem(item music.QueueItem) (queueItemView, error) { }, nil } -// RenderLyricsButtons renders the lyrics provider buttons for a track -func (h *Handler) RenderLyricsButtons(c *fiber.Ctx) error { +// GetLyricsProviders returns lyrics provider buttons for HTMX or provider list as JSON. +func (h *Handler) GetLyricsProviders(c *fiber.Ctx) error { trackID := c.Params("trackId") if trackID == "" { return c.Status(fiber.StatusBadRequest).SendString("Track ID is required") } - // Get track data for button context + providers := h.service.GetLyricsProvidersInfo() + + if c.Get("HX-Request") != "true" { + return c.JSON(providers) + } + track, err := h.metadataService.GetTrackFileTags(c.Context(), trackID) if err != nil { - slog.Error("Failed to get track for lyrics buttons", "error", err, "trackId", trackID) + slog.Error("Failed to get track for lyrics providers", "error", err, "trackId", trackID) return c.Status(fiber.StatusInternalServerError).SendString("Failed to load track data") } - return c.Render("tag/lyrics_buttons", fiber.Map{ + return respond.Partial(c, "tag/lyrics_buttons", fiber.Map{ "Track": track, - "LyricsProviders": h.service.GetLyricsProvidersInfo(), + "LyricsProviders": providers, }) } -// GetLyricsText returns plain lyrics text for HTMX to set in textarea +// GetLyricsText returns plain lyrics text for HTMX to set in textarea, or JSON for API clients. func (h *Handler) GetLyricsText(c *fiber.Ctx) error { trackID := c.Params("trackId") providerName := c.Params("provider") if trackID == "" || providerName == "" { - return c.Status(fiber.StatusBadRequest).SendString("Track ID and provider name are required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Track ID and provider name are required") } - // Fetch lyrics lyrics, err := h.service.SearchLyrics(c.Context(), trackID, providerName) if err != nil { if errors.Is(err, ErrNotFound) { - return c.Status(fiber.StatusNotFound).SendString("No lyrics found for this track") + return respond.ToastErr(c, fiber.StatusNotFound, "No lyrics found for this track") } slog.Error("Failed to fetch lyrics", "error", err, "trackId", trackID, "provider", providerName) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to fetch lyrics") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to fetch lyrics") } slog.Info("Lyrics fetched successfully", "trackId", trackID, "provider", providerName, "lyricsLength", len(lyrics)) - // Return plain lyrics text for HTMX to set in textarea - return c.SendString(lyrics) + if c.Get("HX-Request") == "true" { + return c.SendString(lyrics) + } + return c.JSON(fiber.Map{"track_id": trackID, "lyrics": lyrics}) } // GetTrackLyrics returns the lyrics of a track in plain text. @@ -121,7 +128,7 @@ func (h *Handler) GetTrackLyrics(c *fiber.Ctx) error { if track == nil { return c.Status(fiber.StatusNotFound).SendString("Track not found") } - return c.SendString(track.Metadata.Lyrics) + return respond.Text(c, "lyrics", track.Metadata.Lyrics) } // GetQueueNewLyrics returns the new lyrics from a queue item's metadata. @@ -133,9 +140,9 @@ func (h *Handler) GetQueueNewLyrics(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).SendString("Queue item not found") } if newLyrics, ok := item.Metadata["new_lyrics"]; ok && newLyrics != "" { - return c.SendString(newLyrics) + return respond.Text(c, "lyrics", newLyrics) } - return c.SendString("No new lyrics available") + return respond.Text(c, "lyrics", "", "No new lyrics available") } // RenderLyricsQueueItems renders the lyrics queue content for HTMX @@ -169,7 +176,7 @@ func (h *Handler) RenderLyricsQueueItems(c *fiber.Ctx) error { slog.Info("First queue item sample", "id", queueItems[0].ID, "type", queueItems[0].Type, "trackTitle", queueItems[0].Track.Title) } - return c.Render("lyrics/queue_items", fiber.Map{ + return respond.Partial(c, "lyrics/queue_items", fiber.Map{ "QueueItems": queueItems, }) } @@ -182,11 +189,8 @@ func (h *Handler) ProcessLyricsQueueItem(c *fiber.Ctx) error { err := h.service.ProcessLyricsQueueItem(c.Context(), itemID, action) if err != nil { slog.Error("Failed to process lyrics queue item", "error", err, "itemID", itemID, "action", action) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": fmt.Sprintf("Failed to process lyrics queue item: %s", err.Error()), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to process lyrics queue item: %s", err.Error())) } - // Return success response that updates the UI actionMsg := "processed" switch action { case "override": @@ -201,18 +205,17 @@ func (h *Handler) ProcessLyricsQueueItem(c *fiber.Ctx) error { actionMsg = "skipped" } c.Response().Header.Set("HX-Trigger", "lyricsQueueUpdated,refreshLyricsQueueBadge,updateLyricsQueueCount,activateIndividualGroupingLyrics") - return c.Render("toast/toastOk", fiber.Map{ - "Msg": fmt.Sprintf("Track %s successfully", actionMsg), - }) + return respond.ToastOk(c, fmt.Sprintf("Track %s successfully", actionMsg)) } // LyricsQueueCount returns the current lyrics queue count formatted as "(X)" or empty if 0 func (h *Handler) LyricsQueueCount(c *fiber.Ctx) error { count := len(h.service.GetLyricsQueueItems()) - if count == 0 { - return c.SendString("") + formatted := "" + if count > 0 { + formatted = fmt.Sprintf("(%d)", count) } - return c.SendString(fmt.Sprintf("(%d)", count)) + return respond.Text(c, "queue_count", count, formatted) } // ClearLyricsQueue handles clearing all items from the lyrics queue @@ -220,14 +223,10 @@ func (h *Handler) ClearLyricsQueue(c *fiber.Ctx) error { err := h.service.ClearLyricsQueue() if err != nil { slog.Error("Failed to clear lyrics queue", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to clear lyrics queue", - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to clear lyrics queue") } c.Response().Header.Set("HX-Trigger", "lyricsQueueUpdated,refreshLyricsQueueBadge,updateLyricsQueueCount,activateIndividualGroupingLyrics") - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Lyrics queue cleared successfully", - }) + return respond.ToastOk(c, "Lyrics queue cleared successfully") } // RenderGroupedLyricsQueueItems renders lyrics queue items grouped by artist or album @@ -266,7 +265,7 @@ func (h *Handler) RenderGroupedLyricsQueueItems(c *fiber.Ctx) error { } } - return c.Render(templateName, fiber.Map{ + return respond.Partial(c, templateName, fiber.Map{ "Groups": viewGroups, "GroupType": groupType, }) @@ -295,28 +294,24 @@ func (h *Handler) ProcessLyricsQueueGroup(c *fiber.Ctx) error { err = h.service.ProcessLyricsQueueGroup(c.Context(), decodedGroupKey, groupType, action) if err != nil { slog.Error("Failed to process lyrics queue group", "error", err, "groupKey", decodedGroupKey, "groupType", groupType, "action", action) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": fmt.Sprintf("Failed to process group %s", decodedGroupKey), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to process group %s", decodedGroupKey)) } trigger := "lyricsQueueUpdated,refreshLyricsQueueBadge,updateLyricsQueueCount" if groupType == "artist" { trigger += ",activateArtistGroupingLyrics" - } else if groupType == "album" { + } else { trigger += ",activateAlbumGroupingLyrics" } c.Response().Header.Set("HX-Trigger", trigger) - return c.Render("toast/toastOk", fiber.Map{ - "Msg": fmt.Sprintf("Group '%s' processed successfully", decodedGroupKey), - }) + return respond.ToastOk(c, fmt.Sprintf("Group '%s' processed successfully", decodedGroupKey)) } // RenderLyricsQueueHeader renders the lyrics queue header for HTMX func (h *Handler) RenderLyricsQueueHeader(c *fiber.Ctx) error { slog.Debug("RenderLyricsQueueHeader handler called") count := len(h.service.GetLyricsQueueItems()) - return c.Render("lyrics/queue_header", fiber.Map{ + return respond.Partial(c, "lyrics/queue_header", fiber.Map{ "QueueCount": count, }) } @@ -328,9 +323,7 @@ func (h *Handler) StartLyricsAnalysis(c *fiber.Ctx) error { // Get provider from form data provider := c.FormValue("provider") if provider == "" { - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Please select a lyrics provider", - }) + return respond.ToastErr(c, fiber.StatusBadRequest, "Please select a lyrics provider") } // Get options from form data @@ -345,40 +338,21 @@ func (h *Handler) StartLyricsAnalysis(c *fiber.Ctx) error { jobID, err := h.service.StartLyricsAnalysis(c.Context(), provider, skipExistingLyrics, overrideNoQueue) if err != nil { slog.Error("Failed to start lyrics analysis", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to start lyrics analysis: " + err.Error(), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start lyrics analysis: "+err.Error()) } slog.Info("Lyrics analysis job started successfully", "jobID", jobID, "provider", provider) // Trigger HTMX to refresh the job list c.Set("HX-Trigger", "refreshJobList") - - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Lyrics analysis started successfully", - }) - } - - return c.Redirect("/ui/analyze/lyrics") + return respond.ToastJob(c, jobID, "Lyrics analysis started successfully") } // RenderLyricsAnalysisSection renders the lyrics analysis section page func (h *Handler) RenderLyricsAnalysisSection(c *fiber.Ctx) error { slog.Debug("Rendering lyrics analysis section") - - data := fiber.Map{ - "Title": "Lyrics Analysis", - } - - // Get lyrics providers info for the UI - data["LyricsProviders"] = h.service.GetLyricsProvidersInfo() - - if c.Get("HX-Request") != "true" { - data["Section"] = "analyze_lyrics" - return c.Render("main", data) - } - - return c.Render("sections/analyze_lyrics", data) + return respond.Section(c, "analyze_lyrics", fiber.Map{ + "Title": "Lyrics Analysis", + "LyricsProviders": h.service.GetLyricsProvidersInfo(), + }) } diff --git a/src/features/lyrics/lyrics_job.go b/src/features/lyrics/lyrics_job.go index 9e0a2aa7..77c2cba0 100644 --- a/src/features/lyrics/lyrics_job.go +++ b/src/features/lyrics/lyrics_job.go @@ -141,7 +141,7 @@ func (t *LyricsJobTask) Execute(ctx context.Context, job *music.Job, progressUpd job.Logger.Info("Fetching lyrics for track", "trackID", track.ID, "title", track.Title, "artist", track.Artists, "album", track.Album, "provider", provider, "overrideNoQueue", overrideNoQueue, "color", "cyan") result, err := t.service.AddLyrics(ctx, track.ID, provider, overrideNoQueue) if err != nil { - job.Logger.Error("Failed to add lyrics for track", "trackID", track.ID, "title", track.Title, "provider", provider, "error", err.Error(), "manual_fix", "track") + job.Logger.Error("Failed to add lyrics for track", "trackID", track.ID, "title", track.Title, "provider", provider, "error", err.Error(), "manual_fix", "track") errors++ // Continue with other tracks - don't fail the entire job } else { diff --git a/src/features/lyrics/routes.go b/src/features/lyrics/routes.go index e15a2a62..6f8318e3 100644 --- a/src/features/lyrics/routes.go +++ b/src/features/lyrics/routes.go @@ -6,24 +6,15 @@ import ( // RegisterRoutes registers lyrics routes func RegisterRoutes(app *fiber.App, handler *Handler) { - // UI routes for HTMX partials - ui := app.Group("/ui") - // Lyrics queue UI routes - ui.Get("/lyrics/queue/header", handler.RenderLyricsQueueHeader) - ui.Get("/lyrics/queue/items", handler.RenderLyricsQueueItems) - ui.Get("/lyrics/queue/items/grouped", handler.RenderGroupedLyricsQueueItems) - tagGroup := ui.Group("/tag") + tag := app.Group("/tag") + tag.Get("/:trackId/lyrics", handler.GetLyricsProviders) + tag.Get("/:trackId/lyrics/text/:provider", handler.GetLyricsText) - // Lyrics routes - these are accessed from the metadata/tag UI - tagGroup.Get("/edit/:trackId/lyrics/text/:provider", handler.GetLyricsText) - tagGroup.Get("/buttons/lyrics/:trackId", handler.RenderLyricsButtons) - - // Library routes for lyrics library := app.Group("/library") library.Get("/tracks/:id/lyrics", handler.GetTrackLyrics) - // Lyrics queue routes queue := app.Group("/lyrics/queue") + queue.Get("/header", handler.RenderLyricsQueueHeader) queue.Get("/items", handler.RenderLyricsQueueItems) queue.Get("/items/grouped", handler.RenderGroupedLyricsQueueItems) queue.Post("/:id/:action", handler.ProcessLyricsQueueItem) @@ -32,10 +23,7 @@ func RegisterRoutes(app *fiber.App, handler *Handler) { queue.Get("/count", handler.LyricsQueueCount) queue.Get("/:id/new_lyrics", handler.GetQueueNewLyrics) - // Analyze routes - lyrics analysis analyze := app.Group("/analyze") analyze.Post("/lyrics", handler.StartLyricsAnalysis) - - // UI routes for lyrics analysis section - ui.Get("/analyze/lyrics", handler.RenderLyricsAnalysisSection) + analyze.Get("/lyrics", handler.RenderLyricsAnalysisSection) } diff --git a/src/features/metadata/handlers.go b/src/features/metadata/handlers.go index ba18e1cb..acd75efa 100644 --- a/src/features/metadata/handlers.go +++ b/src/features/metadata/handlers.go @@ -5,6 +5,7 @@ import ( "log/slog" "strings" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/contre95/soulsolid/src/music" "github.com/gofiber/fiber/v2" ) @@ -25,14 +26,22 @@ func (h *Handler) RenderTagEditor(c *fiber.Ctx) error { trackID := c.Params("trackId") if trackID == "" { - return c.Status(fiber.StatusBadRequest).SendString("Track ID is required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Track ID is required") } - // Get track data for editing - track, err := h.service.GetTrackFileTags(c.Context(), trackID) + var track *music.Track + var err error + if c.Query("source", "file") == "db" { + track, err = h.service.libraryRepo.GetTrack(c.Context(), trackID) + if err == nil && track == nil { + return respond.ToastErr(c, fiber.StatusNotFound, "Track not found") + } + } else { + track, err = h.service.GetTrackFileTags(c.Context(), trackID) + } if err != nil { slog.Error("Failed to get track for editing", "error", err, "trackId", trackID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to load track data") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to load track data") } // Fetch all artists and albums for dropdowns @@ -105,22 +114,8 @@ func (h *Handler) RenderTagEditor(c *fiber.Ctx) error { } } - // Check if request is HTMX or full page - if c.Get("HX-Request") == "true" { - // Return just the section content for HTMX requests - return c.Render("sections/tag", fiber.Map{ - "Track": track, - "Artists": artists, - "Albums": albums, - "SelectedAlbumArtistID": selectedAlbumArtistID, - "SelectedArtistIDs": selectedArtistIDs, - }) - } - - // Return full page for direct navigation - return c.Render("main", fiber.Map{ + return respond.Section(c, "tag", fiber.Map{ "Track": track, - "IsTagEdit": true, "Artists": artists, "Albums": albums, "SelectedAlbumArtistID": selectedAlbumArtistID, @@ -167,21 +162,20 @@ func (h *Handler) getProviderColors(providerName string) map[string]string { func (h *Handler) ServeArtwork(c *fiber.Ctx) error { trackID := c.Params("trackId") if trackID == "" { - return c.Status(fiber.StatusBadRequest).SendString("Track ID is required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Track ID is required") } data, mimeType, err := h.service.GetTrackArtwork(c.Context(), trackID) - if err != nil { + if err != nil || len(data) == 0 { slog.Warn("Failed to read artwork", "trackId", trackID, "error", err) - return c.Status(fiber.StatusNotFound).SendString("Artwork not found") - } - if len(data) == 0 { - return c.Status(fiber.StatusNotFound).SendString("No artwork embedded in file") + return respond.ToastErr(c, fiber.StatusNotFound, "Artwork not found") } - c.Set("Content-Type", mimeType) - c.Set("Cache-Control", "public, max-age=3600") - return c.Send(data) + return respond.Resource(c, mimeType, fmt.Sprintf("%s/tag/%s/artwork", c.BaseURL(), trackID), func() error { + c.Set("Content-Type", mimeType) + c.Set("Cache-Control", "public, max-age=3600") + return c.Send(data) + }) } // FetchFromProvider handles fetching metadata from any provider and rendering the form @@ -290,29 +284,15 @@ func (h *Handler) FetchFromProvider(c *fiber.Ctx) error { // Get provider colors providerColors := h.getProviderColors(providerName) - // Check if request is HTMX or full page - if c.Get("HX-Request") == "true" { - return c.Render("sections/tag", fiber.Map{ - "Track": track, - "Artists": artists, - "Albums": albums, - "FetchError": "err", - "ProviderColors": providerColors, - "SelectedAlbumArtistID": selectedAlbumArtistID, - "SelectedArtistIDs": selectedArtistIDs, - }) - } else { - return c.Render("main", fiber.Map{ - "Track": track, - "IsTagEdit": true, - "Artists": artists, - "Albums": albums, - "FetchError": "err", - "ProviderColors": providerColors, - "SelectedAlbumArtistID": selectedAlbumArtistID, - "SelectedArtistIDs": selectedArtistIDs, - }) - } + return respond.Section(c, "tag", fiber.Map{ + "Track": track, + "Artists": artists, + "Albums": albums, + "FetchError": "err", + "ProviderColors": providerColors, + "SelectedAlbumArtistID": selectedAlbumArtistID, + "SelectedArtistIDs": selectedArtistIDs, + }) } // Use the first track from search results @@ -401,37 +381,15 @@ func (h *Handler) FetchFromProvider(c *fiber.Ctx) error { // Get provider colors providerColors := h.getProviderColors(providerName) - // Check if request is HTMX or full page - if c.Get("HX-Request") == "true" { - return c.Render("sections/tag", fiber.Map{ - "Track": track, - "Artists": artists, - "Albums": albums, - "FromProvider": providerName, - "ProviderColors": providerColors, - "SelectedAlbumArtistID": selectedAlbumArtistID, - "SelectedArtistIDs": selectedArtistIDs, - }) - } else { - return c.Render("main", fiber.Map{ - "Track": track, - "IsTagEdit": true, - "Artists": artists, - "Albums": albums, - "FromProvider": providerName, - "ProviderColors": providerColors, - "SelectedAlbumArtistID": selectedAlbumArtistID, - "SelectedArtistIDs": selectedArtistIDs, - }) - } -} - -// ModalData holds data for the search results modal -type ModalData struct { - Tracks []*music.Track - ProviderName string - ProviderColors map[string]string - TrackID string + return respond.Section(c, "tag", fiber.Map{ + "Track": track, + "Artists": artists, + "Albums": albums, + "FromProvider": providerName, + "ProviderColors": providerColors, + "SelectedAlbumArtistID": selectedAlbumArtistID, + "SelectedArtistIDs": selectedArtistIDs, + }) } // SearchTracksFromProvider handles searching for tracks from a specific provider @@ -447,20 +405,17 @@ func (h *Handler) SearchTracksFromProvider(c *fiber.Ctx) error { tracks, err := h.service.SearchTrackMetadata(c.Context(), trackID, providerName) if err != nil { slog.Error("Failed to search tracks", "error", err, "trackId", trackID, "provider", providerName) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": fmt.Sprintf("Failed to search tracks: %v", err), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to search tracks: %v", err)) } // Get provider colors for styling providerColors := h.getProviderColors(providerName) - // Render modal with search results - return c.Render("tag/search_results_modal", ModalData{ - Tracks: tracks, - ProviderName: providerName, - ProviderColors: providerColors, - TrackID: trackID, + return respond.Partial(c, "tag/search_results_modal", fiber.Map{ + "Tracks": tracks, + "ProviderName": providerName, + "ProviderColors": providerColors, + "TrackID": trackID, }) } @@ -596,8 +551,7 @@ func (h *Handler) SelectTrackFromResults(c *fiber.Ctx) error { // Get provider colors providerColors := h.getProviderColors(providerName) - // Render the updated form - return c.Render("sections/tag", fiber.Map{ + return respond.Section(c, "tag", fiber.Map{ "Track": mergedTrack, "Artists": artists, "Albums": albums, @@ -620,18 +574,11 @@ func (h *Handler) CalculateFingerprint(c *fiber.Ctx) error { err := h.service.AddChromaprintAndAcoustID(c.Context(), trackID) if err != nil { slog.Error("Failed to calculate fingerprint", "error", err, "trackId", trackID) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": fmt.Sprintf("Failed to calculate fingerprint: %v", err), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate fingerprint: %v", err)) } - // Set HTMX header to refresh the edit form after successful calculation c.Set("HX-Trigger", "refreshEditForm") - - // Return success toast - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Fingerprint calculated successfully!", - }) + return respond.ToastOk(c, "Fingerprint calculated successfully!") } // ViewFingerprint handles viewing fingerprint @@ -650,31 +597,33 @@ func (h *Handler) ViewFingerprint(c *fiber.Ctx) error { } if track.ChromaprintFingerprint == "" { - return c.SendString("No fingerprint available for this track.") + return respond.Text(c, "fingerprint", "", "No fingerprint available for this track.") } - - // Return raw text - c.Set("Content-Type", "text/plain") - return c.SendString(track.ChromaprintFingerprint) + return respond.Text(c, "fingerprint", track.ChromaprintFingerprint) } -// RenderMetadataButtons renders the metadata provider buttons for a track -func (h *Handler) RenderMetadataButtons(c *fiber.Ctx) error { +// GetMetadataProviders returns metadata provider buttons for HTMX or provider list as JSON. +func (h *Handler) GetMetadataProviders(c *fiber.Ctx) error { trackID := c.Params("trackId") if trackID == "" { return c.Status(fiber.StatusBadRequest).SendString("Track ID is required") } - // Get track data for button context + providers := h.service.GetEnabledMetadataProviders() + + if c.Get("HX-Request") != "true" { + return c.JSON(providers) + } + track, err := h.service.GetTrackFileTags(c.Context(), trackID) if err != nil { - slog.Error("Failed to get track for buttons", "error", err, "trackId", trackID) + slog.Error("Failed to get track for metadata providers", "error", err, "trackId", trackID) return c.Status(fiber.StatusInternalServerError).SendString("Failed to load track data") } - return c.Render("tag/metadata_buttons", fiber.Map{ + return respond.Partial(c, "tag/metadata_buttons", fiber.Map{ "Track": track, - "EnabledProviders": h.service.GetEnabledMetadataProviders(), + "EnabledProviders": providers, }) } @@ -759,15 +708,10 @@ func (h *Handler) UpdateTags(c *fiber.Ctx) error { err := h.service.UpdateTrackTags(c.Context(), trackID, formData) if err != nil { slog.Error("Failed to update track tags", "error", err, "trackId", trackID) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": fmt.Sprintf("Failed to update tags: %v", err), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to update tags: %v", err)) } - // Return success toast - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Tags updated successfully!", - }) + return respond.ToastOk(c, "Tags updated successfully!") } // StartAcoustIDAnalysis handles starting the AcoustID analysis job @@ -777,37 +721,18 @@ func (h *Handler) StartAcoustIDAnalysis(c *fiber.Ctx) error { jobID, err := h.service.StartAcoustIDAnalysis(c.Context()) if err != nil { slog.Error("Failed to start AcoustID analysis", "error", err) - return c.Render("toast/toastErr", fiber.Map{ - "Msg": "Failed to start AcoustID analysis: " + err.Error(), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start AcoustID analysis: "+err.Error()) } slog.Info("AcoustID analysis job started successfully", "jobID", jobID) // // Trigger HTMX to refresh the job list c.Set("HX-Trigger", "refreshJobList") - - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "AcoustID analysis started successfully", - }) - } - - return c.Redirect("/ui/analyze/metadata") + return respond.ToastJob(c, jobID, "AcoustID analysis started successfully") } // RenderMetadataAnalysisSection renders the metadata analysis section page func (h *Handler) RenderMetadataAnalysisSection(c *fiber.Ctx) error { slog.Debug("Rendering metadata analysis section") - - data := fiber.Map{ - "Title": "Metadata Analysis", - } - - if c.Get("HX-Request") != "true" { - data["Section"] = "analyze_metadata" - return c.Render("main", data) - } - - return c.Render("sections/analyze_metadata", data) + return respond.Section(c, "analyze_metadata", fiber.Map{"Title": "Metadata Analysis"}) } diff --git a/src/features/metadata/routes.go b/src/features/metadata/routes.go index df57f8d1..c42cdceb 100644 --- a/src/features/metadata/routes.go +++ b/src/features/metadata/routes.go @@ -8,32 +8,19 @@ import ( func RegisterRoutes(app *fiber.App, service *Service) { handler := NewHandler(service) - // UI routes for page rendering - ui := app.Group("/ui") - ui.Get("/tag/edit/:trackId", handler.RenderTagEditor) - ui.Get("/tag/edit/:trackId/artwork", handler.ServeArtwork) - ui.Get("/tag/edit/:trackId/fetch/:provider", handler.FetchFromProvider) - ui.Get("/tag/edit/:trackId/search/:provider", handler.SearchTracksFromProvider) - ui.Get("/tag/edit/:trackId/select/:provider", handler.SelectTrackFromResults) - ui.Get("/tag/edit/:trackId/fingerprint", handler.CalculateFingerprint) - ui.Get("/tag/edit/:trackId/fingerprint/view", handler.ViewFingerprint) - ui.Get("/tag/buttons/metadata/:trackId", handler.RenderMetadataButtons) + tag := app.Group("/tag") + tag.Get("/:trackId/metadata", handler.GetMetadataProviders) + tag.Get("/:trackId/artwork", handler.ServeArtwork) + tag.Get("/:trackId/fingerprint", handler.CalculateFingerprint) + tag.Get("/:trackId/fingerprint/view", handler.ViewFingerprint) + tag.Get("/:trackId/search/:provider", handler.SearchTracksFromProvider) + tag.Get("/:trackId/select/:provider", handler.SelectTrackFromResults) + tag.Get("/:trackId", handler.RenderTagEditor) + tag.Post("/:trackId", handler.UpdateTags) + tag.Get("/:trackId/:provider", handler.FetchFromProvider) - // Analyze routes - metadata analysis analyze := app.Group("/analyze") analyze.Post("/acoustid", handler.StartAcoustIDAnalysis) - // UI routes for metadata analysis section - ui.Get("/analyze/metadata", handler.RenderMetadataAnalysisSection) - - // API routes for data operations - tagGroup := app.Group("/tag") - tagGroup.Get("/edit/:trackId", handler.RenderTagEditor) - tagGroup.Get("/tag/edit/:trackId/fetch/:provider", handler.FetchFromProvider) - tagGroup.Get("/edit/:trackId/fetch/:provider", handler.FetchFromProvider) - tagGroup.Get("/edit/:trackId/search/:provider", handler.SearchTracksFromProvider) - tagGroup.Get("/edit/:trackId/select/:provider", handler.SelectTrackFromResults) - tagGroup.Get("/edit/:trackId/fingerprint", handler.CalculateFingerprint) - tagGroup.Get("/edit/:trackId/fingerprint/view", handler.ViewFingerprint) - tagGroup.Post("/:trackId", handler.UpdateTags) + app.Get("/analyze/metadata", handler.RenderMetadataAnalysisSection) } diff --git a/src/features/metrics/handlers.go b/src/features/metrics/handlers.go index a5041b38..9a6dc304 100644 --- a/src/features/metrics/handlers.go +++ b/src/features/metrics/handlers.go @@ -2,8 +2,8 @@ package metrics import ( "log/slog" - "strings" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/gofiber/fiber/v2" ) @@ -27,16 +27,7 @@ func (h *Handler) GetMetricsOverview(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).SendString("Error loading metrics") } - // Check if request accepts HTML (HTMX request) - acceptHeader := c.Get("Accept") - hxRequest := c.Get("HX-Request") - if strings.Contains(acceptHeader, "text/html") || hxRequest == "true" { - return c.Render("metrics/overview", fiber.Map{ - "Metrics": metrics, - }) - } - - return c.JSON(metrics) + return respond.Partial(c, "metrics/overview", fiber.Map{"Metrics": metrics}) } // GetGenreChartHTML returns genre chart as HTML fragment for HTMX. @@ -50,7 +41,7 @@ func (h *Handler) GetGenreChartHTML(c *fiber.Ctx) error { } chartData := metrics.GenreChartData() - return c.Render("metrics/charts/genre_treemap", fiber.Map{ + return respond.Partial(c, "metrics/charts/genre_treemap", fiber.Map{ "ChartData": chartData, }) } @@ -66,7 +57,7 @@ func (h *Handler) GetYearChartHTML(c *fiber.Ctx) error { } chartData := metrics.YearBarData() - return c.Render("metrics/charts/year_vbars", fiber.Map{ + return respond.Partial(c, "metrics/charts/year_vbars", fiber.Map{ "ChartData": chartData, }) } @@ -82,7 +73,7 @@ func (h *Handler) GetFormatChartHTML(c *fiber.Ctx) error { } chartData := metrics.FormatBarData() - return c.Render("metrics/charts/format_pie", fiber.Map{ + return respond.Partial(c, "metrics/charts/format_pie", fiber.Map{ "ChartData": chartData, }) } @@ -98,7 +89,7 @@ func (h *Handler) GetMetadataChartHTML(c *fiber.Ctx) error { } if totalTracks == 0 { - return c.Render("metrics/charts/metadata_hbars", fiber.Map{ + return respond.Partial(c, "metrics/charts/metadata_hbars", fiber.Map{ "ChartData": nil, }) } @@ -146,7 +137,7 @@ func (h *Handler) GetMetadataChartHTML(c *fiber.Ctx) error { Colors: []string{"#00E396", "#FEB019", "#FF4560", "#008FFB", "#775DD0"}, } - return c.Render("metrics/charts/metadata_hbars", fiber.Map{ + return respond.Partial(c, "metrics/charts/metadata_hbars", fiber.Map{ "ChartData": chartData, }) } diff --git a/src/features/metrics/routes.go b/src/features/metrics/routes.go index f0a0b359..a1a29fb7 100644 --- a/src/features/metrics/routes.go +++ b/src/features/metrics/routes.go @@ -6,13 +6,10 @@ import ( // RegisterRoutes registers the metrics routes with the Fiber app. func RegisterRoutes(app *fiber.App, handler *Handler) { - // UI routes for HTMX partials - ui := app.Group("/ui/metrics") - ui.Get("/overview", handler.GetMetricsOverview) - - // HTMX chart endpoints - ui.Get("/charts/genre", handler.GetGenreChartHTML) - ui.Get("/charts/year", handler.GetYearChartHTML) - ui.Get("/charts/format", handler.GetFormatChartHTML) - ui.Get("/charts/metadata", handler.GetMetadataChartHTML) + metrics := app.Group("/metrics") + metrics.Get("/overview", handler.GetMetricsOverview) + metrics.Get("/charts/genre", handler.GetGenreChartHTML) + metrics.Get("/charts/year", handler.GetYearChartHTML) + metrics.Get("/charts/format", handler.GetFormatChartHTML) + metrics.Get("/charts/metadata", handler.GetMetadataChartHTML) } diff --git a/src/features/playlists/handlers.go b/src/features/playlists/handlers.go index b1da668e..6bcec368 100644 --- a/src/features/playlists/handlers.go +++ b/src/features/playlists/handlers.go @@ -5,6 +5,7 @@ import ( "log/slog" "strings" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/contre95/soulsolid/src/music" "github.com/gofiber/fiber/v2" ) @@ -29,15 +30,10 @@ func (h *Handler) RenderPlaylistsSection(c *fiber.Ctx) error { playlists = []*music.Playlist{} // Continue with empty list } - data := fiber.Map{ + return respond.Section(c, "playlists", fiber.Map{ "Title": "Playlists", "Playlists": playlists, - } - if c.Get("HX-Request") != "true" { - data["Section"] = "playlists" - return c.Render("main", data) - } - return c.Render("sections/playlists", data) + }) } // GetPlaylist renders a single playlist page. @@ -53,16 +49,10 @@ func (h *Handler) GetPlaylist(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).SendString("Playlist not found") } - data := fiber.Map{ + return respond.Partial(c, "playlists/playlist", fiber.Map{ "Title": fmt.Sprintf("Playlist: %s", playlist.Name), "Playlist": playlist, - } - - if c.Get("HX-Request") != "true" { - // For direct navigation to specific playlist, render main with Playlist data (no Section set) - return c.Render("main", data) - } - return c.Render("playlists/playlist", data) + }) } // CreatePlaylist handles creating a new playlist. @@ -73,18 +63,17 @@ func (h *Handler) CreatePlaylist(c *fiber.Ctx) error { description := c.FormValue("description") if name == "" { - return c.Status(fiber.StatusBadRequest).SendString("Playlist name is required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Playlist name is required") } _, err := h.service.CreatePlaylist(c.Context(), name, description) if err != nil { slog.Error("Error creating playlist", "error", err) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to create playlist") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to create playlist") } - // Trigger playlist refresh and return success toast c.Set("HX-Trigger", "refreshPlaylists") - return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist created successfully"}) + return respond.ToastOk(c, "Playlist created successfully") } // UpdatePlaylist handles updating a playlist. @@ -96,16 +85,16 @@ func (h *Handler) UpdatePlaylist(c *fiber.Ctx) error { description := c.FormValue("description") if name == "" { - return c.Status(fiber.StatusBadRequest).SendString("Playlist name is required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Playlist name is required") } playlist, err := h.service.GetPlaylist(c.Context(), playlistID) if err != nil { slog.Error("Error loading playlist for update", "error", err, "id", playlistID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlist") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to load playlist") } if playlist == nil { - return c.Status(fiber.StatusNotFound).SendString("Playlist not found") + return respond.ToastErr(c, fiber.StatusNotFound, "Playlist not found") } playlist.Name = name @@ -114,11 +103,10 @@ func (h *Handler) UpdatePlaylist(c *fiber.Ctx) error { err = h.service.UpdatePlaylist(c.Context(), playlist) if err != nil { slog.Error("Error updating playlist", "error", err, "id", playlistID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to update playlist") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to update playlist") } - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist updated successfully"}) + return respond.ToastOk(c, "Playlist updated successfully") } // DeletePlaylist handles deleting a playlist. @@ -128,12 +116,11 @@ func (h *Handler) DeletePlaylist(c *fiber.Ctx) error { err := h.service.DeletePlaylist(c.Context(), c.Params("id")) if err != nil { slog.Error("Error deleting playlist", "error", err, "id", c.Params("id")) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to delete playlist") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to delete playlist") } - // Trigger playlist refresh and return success toast c.Set("HX-Trigger", "refreshPlaylists") - return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist deleted successfully"}) + return respond.ToastOk(c, "Playlist deleted successfully") } // AddItemToPlaylist handles adding tracks, artists, or albums to a playlist. @@ -146,13 +133,13 @@ func (h *Handler) AddItemToPlaylist(c *fiber.Ctx) error { if playlistID == "" || itemType == "" || itemID == "" { slog.Error("AddItemToPlaylist: missing required parameters", "playlistID", playlistID, "itemType", itemType, "itemID", itemID) - return c.Status(fiber.StatusBadRequest).SendString("Playlist ID, item type, and item ID are required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Playlist ID, item type, and item ID are required") } err := h.service.AddItemToPlaylist(c.Context(), playlistID, itemType, itemID) if err != nil { slog.Error("Error adding item to playlist", "error", err, "playlistID", playlistID, "itemType", itemType, "itemID", itemID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to add item to playlist") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to add item to playlist") } // Get item name for success message @@ -186,9 +173,8 @@ func (h *Handler) AddItemToPlaylist(c *fiber.Ctx) error { slog.Info("Item successfully added to playlist", "playlistID", playlistID, "itemType", itemType, "itemID", itemID) - // Trigger playlist refresh and return success toast c.Set("HX-Trigger", "playlistUpdated") - return c.Render("toast/toastOk", fiber.Map{"Msg": successMsg}) + return respond.ToastOk(c, successMsg) } // RemoveTrackFromPlaylist handles removing a track from a playlist. @@ -199,25 +185,24 @@ func (h *Handler) RemoveTrackFromPlaylist(c *fiber.Ctx) error { trackID := c.Params("trackId") if playlistID == "" || trackID == "" { - return c.Status(fiber.StatusBadRequest).SendString("Playlist ID and Track ID are required") + return respond.ToastErr(c, fiber.StatusBadRequest, "Playlist ID and Track ID are required") } err := h.service.RemoveTrackFromPlaylist(c.Context(), playlistID, trackID) if err != nil { slog.Error("Error removing track from playlist", "error", err, "playlistID", playlistID, "trackID", trackID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to remove track from playlist") + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to remove track from playlist") } - // Trigger playlist refresh and return success toast c.Set("HX-Trigger", "playlistUpdated") - return c.Render("toast/toastOk", fiber.Map{"Msg": "Track removed from playlist"}) + return respond.ToastOk(c, "Track removed from playlist") } // GetPlaylistCreationModal returns the create playlist modal. func (h *Handler) GetPlaylistCreationModal(c *fiber.Ctx) error { slog.Debug("GetCreatePlaylistModal handler called") - return c.Render("playlists/create_playlist_modal", nil) + return respond.Partial(c, "playlists/create_playlist_modal", fiber.Map{}) } // GetPlaylistsForItem returns playlists for adding tracks, artists, or albums. @@ -265,7 +250,7 @@ func (h *Handler) GetPlaylistsForItem(c *fiber.Ctx) error { "ItemName": itemName, } - return c.Render("playlists/add_to_playlist_modal", data) + return respond.Partial(c, "playlists/add_to_playlist_modal", data) } // ExportM3U handles exporting a playlist to an M3U file. @@ -308,9 +293,9 @@ func (h *Handler) ExportM3U(c *fiber.Ctx) error { m3uContent := builder.String() filename := fmt.Sprintf("%s.m3u", playlist.Name) - // Set headers for inline display in new tab - c.Set("Content-Type", "text/plain") - c.Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename)) - - return c.SendString(m3uContent) + return respond.Resource(c, "audio/x-mpegurl", fmt.Sprintf("%s/playlists/%s/export", c.BaseURL(), playlistID), func() error { + c.Set("Content-Type", "text/plain") + c.Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename)) + return c.SendString(m3uContent) + }) } diff --git a/src/features/playlists/routes.go b/src/features/playlists/routes.go index d9fd018d..78883b4e 100644 --- a/src/features/playlists/routes.go +++ b/src/features/playlists/routes.go @@ -8,9 +8,7 @@ import ( func RegisterRoutes(app *fiber.App, service *Service) { handler := NewHandler(service) - ui := app.Group("/ui") - ui.Get("/playlists", handler.RenderPlaylistsSection) - ui.Get("/playlists/:id", handler.GetPlaylist) + app.Get("/playlists", handler.RenderPlaylistsSection) playlists := app.Group("/playlists") playlists.Get("/create-modal", handler.GetPlaylistCreationModal) @@ -20,6 +18,6 @@ func RegisterRoutes(app *fiber.App, service *Service) { playlists.Post("/items", handler.AddItemToPlaylist) playlists.Delete("/:playlistId/tracks/:trackId", handler.RemoveTrackFromPlaylist) playlists.Get("/:type/:id/playlists", handler.GetPlaylistsForItem) - playlists.Get("/:id/export", handler.ExportM3U) + playlists.Get("/:id", handler.GetPlaylist) } diff --git a/src/features/reorganize/handlers.go b/src/features/reorganize/handlers.go index fadb0ab8..d26f6d12 100644 --- a/src/features/reorganize/handlers.go +++ b/src/features/reorganize/handlers.go @@ -4,6 +4,7 @@ import ( "log/slog" "github.com/contre95/soulsolid/src/features/config" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/gofiber/fiber/v2" ) @@ -29,37 +30,20 @@ func (h *Handler) StartReorganizeAnalysis(c *fiber.Ctx) error { jobID, err := h.service.StartReorganizeAnalysis(c.Context(), fat32Safe) if err != nil { slog.Error("Failed to start file reorganization job", "error", err) - return c.Status(fiber.StatusInternalServerError).Render("toast/toastError", fiber.Map{ - "Msg": "Failed to start file reorganization job: " + err.Error(), - }) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to start file reorganization job: "+err.Error()) } slog.Info("File reorganization job started", "jobID", jobID) c.Set("HX-Trigger", "refreshJobList") - - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "File reorganization started successfully", - }) - } - - return c.Redirect("/ui/analyze/files") + return respond.ToastJob(c, jobID, "File reorganization started successfully") } // RenderFilesReorganizationSection renders the file paths section page func (h *Handler) RenderFilesReorganizationSection(c *fiber.Ctx) error { slog.Debug("Rendering file paths section") - - data := fiber.Map{ + return respond.Section(c, "analyze_files", fiber.Map{ "Title": "File Paths", "Config": h.config.Get(), - } - - if c.Get("HX-Request") != "true" { - data["Section"] = "analyze_files" - return c.Render("main", data) - } - - return c.Render("sections/analyze_files", data) + }) } diff --git a/src/features/reorganize/routes.go b/src/features/reorganize/routes.go index bd3be317..722df889 100644 --- a/src/features/reorganize/routes.go +++ b/src/features/reorganize/routes.go @@ -6,10 +6,6 @@ import ( // RegisterRoutes registers the routes for the reorganize feature. func RegisterRoutes(app *fiber.App, handler *Handler) { - // API routes for file reorganization app.Post("/analyze/reorganize", handler.StartReorganizeAnalysis) - - // UI routes for the file reorganization section - ui := app.Group("/ui") - ui.Get("/analyze/files", handler.RenderFilesReorganizationSection) + app.Get("/analyze/files", handler.RenderFilesReorganizationSection) } diff --git a/src/features/streaming/handlers.go b/src/features/streaming/handlers.go new file mode 100644 index 00000000..8b739722 --- /dev/null +++ b/src/features/streaming/handlers.go @@ -0,0 +1,38 @@ +package streaming + +import ( + "log/slog" + "net/url" + + "github.com/gofiber/fiber/v2" +) + +// Handler handles audio streaming requests. +type Handler struct { + service *Service +} + +// NewHandler creates a new streaming handler. +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// Stream serves the file at the given path query parameter. +func (h *Handler) Stream(c *fiber.Ctx) error { + rawPath := c.Query("path") + if rawPath == "" { + return c.Status(fiber.StatusBadRequest).SendString("missing path") + } + path, err := url.QueryUnescape(rawPath) + if err != nil { + return c.Status(fiber.StatusBadRequest).SendString("invalid path") + } + resolved, mimeType, err := h.service.Stream(path) + if err != nil { + slog.Error("Stream: rejected path", "path", path, "error", err) + return c.Status(fiber.StatusNotFound).SendString("track not found") + } + c.Set("Content-Type", mimeType) + c.Set("Accept-Ranges", "bytes") + return c.SendFile(resolved) +} diff --git a/src/features/streaming/routes.go b/src/features/streaming/routes.go new file mode 100644 index 00000000..bb4a1294 --- /dev/null +++ b/src/features/streaming/routes.go @@ -0,0 +1,9 @@ +package streaming + +import "github.com/gofiber/fiber/v2" + +// RegisterRoutes registers the audio streaming routes. +func RegisterRoutes(app *fiber.App, service *Service) { + handler := NewHandler(service) + app.Get("/stream", handler.Stream) +} diff --git a/src/features/streaming/service.go b/src/features/streaming/service.go new file mode 100644 index 00000000..68146fa0 --- /dev/null +++ b/src/features/streaming/service.go @@ -0,0 +1,66 @@ +package streaming + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/contre95/soulsolid/src/features/config" +) + +// containedIn guards against path traversal attacks: it resolves symlinks on +// both paths before the prefix check, so neither ../.. sequences nor symlinks +// inside the allowed directory can be used to escape it and read arbitrary files. +func containedIn(candidate, base string) (string, error) { + resolved, err := filepath.EvalSymlinks(filepath.Clean(candidate)) + if err != nil { + return "", fmt.Errorf("cannot resolve path: %w", err) + } + resolvedBase, err := filepath.EvalSymlinks(filepath.Clean(base)) + if err != nil { + return "", fmt.Errorf("cannot resolve base path: %w", err) + } + if resolved != resolvedBase && !strings.HasPrefix(resolved, resolvedBase+string(filepath.Separator)) { + return "", fmt.Errorf("track path outside allowed directory") + } + return resolved, nil +} + +// Service handles audio streaming by validating and serving file paths. +type Service struct { + cfg *config.Manager +} + +// NewService creates a new streaming service. +func NewService(cfg *config.Manager) *Service { + return &Service{cfg: cfg} +} + +var audioMIME = map[string]string{ + ".mp3": "audio/mpeg", + ".flac": "audio/flac", + // Not supported in soulsolid yet + ".wav": "audio/wav", + ".aac": "audio/aac", + ".m4a": "audio/mp4", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + ".wma": "audio/x-ms-wma", +} + +// Stream validates that path is within the library or download directory, +// has an allowed audio extension, and returns the resolved path and MIME type. +func (s *Service) Stream(path string) (string, string, error) { + cfg := s.cfg.Get() + for _, base := range []string{cfg.LibraryPath, cfg.DownloadPath} { + resolved, err := containedIn(path, base) + if err == nil { + mime, ok := audioMIME[strings.ToLower(filepath.Ext(resolved))] + if !ok { + return "", "", fmt.Errorf("unsupported file type") + } + return resolved, mime, nil + } + } + return "", "", fmt.Errorf("track path outside allowed directories") +} diff --git a/src/features/ui/handlers.go b/src/features/ui/handlers.go index be023932..c4dfa77c 100644 --- a/src/features/ui/handlers.go +++ b/src/features/ui/handlers.go @@ -4,6 +4,7 @@ import ( "log/slog" "github.com/contre95/soulsolid/src/features/config" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/gofiber/fiber/v2" ) @@ -22,34 +23,17 @@ func NewHandler(configManager *config.Manager) *Handler { // RenderDashboard renders the main dashboard page. func (h *Handler) RenderDashboard(c *fiber.Ctx) error { slog.Debug("RenderDashboard handler called") - data := fiber.Map{ - "Title": "Dashboard", - } - if c.Get("HX-Request") != "true" { - data["Section"] = "dashboard" - return c.Render("main", data) - } - return c.Render("sections/dashboard", data) + return respond.Section(c, "dashboard", fiber.Map{"Title": "Dashboard"}) } // GetQuickActionsCard renders the quick actions card for the dashboard. func (h *Handler) GetQuickActionsCard(c *fiber.Ctx) error { slog.Debug("GetQuickActionsCard handler called") - return c.Render("cards/quick_actions", fiber.Map{}) + return respond.Partial(c, "cards/quick_actions", fiber.Map{}) } // RenderAnalyzeSection renders the all analyze jobs page func (h *Handler) RenderAnalyzeSection(c *fiber.Ctx) error { slog.Debug("Rendering all analyze jobs page") - - data := fiber.Map{ - "Title": "All Analyze Jobs", - } - - if c.Get("HX-Request") != "true" { - data["Section"] = "analyze" - return c.Render("main", data) - } - - return c.Render("sections/analyze", data) + return respond.Section(c, "analyze", fiber.Map{"Title": "All Analyze Jobs"}) } diff --git a/src/features/ui/routes.go b/src/features/ui/routes.go index 9ba3a932..101401f4 100644 --- a/src/features/ui/routes.go +++ b/src/features/ui/routes.go @@ -6,19 +6,8 @@ import ( // RegisterRoutes registers the routes for the UI feature. func RegisterRoutes(app *fiber.App, handler *Handler) { - // Create a new group for the UI feature. - app.Get("/", func(c *fiber.Ctx) error { - return c.Redirect("/ui") - }) - ui := app.Group("/ui") - // Register the routes for pages. - ui.Get("/", handler.RenderDashboard) - ui.Get("/dashboard", handler.RenderDashboard) - - // Analyze section - all analyze jobs - ui.Get("/analyze", handler.RenderAnalyzeSection) - - // Dashboard card endpoints - ui.Get("/quick-actions-card", handler.GetQuickActionsCard) - + app.Get("/", handler.RenderDashboard) + app.Get("/dashboard", handler.RenderDashboard) + app.Get("/analyze", handler.RenderAnalyzeSection) + app.Get("/dashboard/quick-actions", handler.GetQuickActionsCard) } diff --git a/src/main.go b/src/main.go index 555e6850..78cd1987 100644 --- a/src/main.go +++ b/src/main.go @@ -18,6 +18,7 @@ import ( "github.com/contre95/soulsolid/src/features/metrics" "github.com/contre95/soulsolid/src/features/playlists" "github.com/contre95/soulsolid/src/features/reorganize" + "github.com/contre95/soulsolid/src/features/streaming" "github.com/contre95/soulsolid/src/infra/database" "github.com/contre95/soulsolid/src/infra/files" "github.com/contre95/soulsolid/src/infra/fingerprint" @@ -133,7 +134,8 @@ func main() { } } - server := hosting.NewServer(cfgManager, importingService, libraryService, playlistsService, downloadingService, jobService, tagService, lyricsService, metricsService, reorganizeService) + streamingService := streaming.NewService(cfgManager) + server := hosting.NewServer(cfgManager, importingService, libraryService, playlistsService, downloadingService, jobService, tagService, lyricsService, metricsService, reorganizeService, streamingService) slog.Info("Starting server", "port", cfgManager.Get().Server.Port) if err := server.Start(); err != nil { slog.Error("server stopped: %v", "error", err) diff --git a/views/cards/quick_actions.html b/views/cards/quick_actions.html index 2a4ecb38..12aecfec 100644 --- a/views/cards/quick_actions.html +++ b/views/cards/quick_actions.html @@ -1,14 +1,14 @@

Quick Actions

- +
Browse Library

Explore your music collection

- +
Import Music diff --git a/views/config/config_form.html b/views/config/config_form.html index 7b5d1cc3..808aaf54 100644 --- a/views/config/config_form.html +++ b/views/config/config_form.html @@ -1,4 +1,4 @@ -
+
diff --git a/views/importing/queue_header.html b/views/importing/queue_header.html index 6e388a36..be3ca659 100644 --- a/views/importing/queue_header.html +++ b/views/importing/queue_header.html @@ -10,12 +10,12 @@

- {{.QueueCount}} items + {{.QueueCount}}

+ + {{if eq .Type "duplicate"}} +
+ + +
+ + + {{else}} +
+ +
+ + {{end}} +

ID: {{.ID}}

Artists ({{len .Track.Artists}}): {{if .Track.Artists}}{{range $i, $ar := .Track.Artists}}{{if $i}}, {{end}}{{$ar.Artist.Name}}{{end}}{{end}}

Album: {{with .Track.Album}}{{.Title}}{{else}}Unknown Album{{end}}

-

Original path: {{.Track.Path}}

+

New: {{.Track.Path}}

{{if len .ItemMetadata}} {{range $key, $value := .ItemMetadata}} -

{{$key}}: {{$value}}

+

{{if eq $key "duplicate_path"}}Existing{{else}}{{$key}}{{end}}: {{$value}}

{{end}} {{end}}
diff --git a/views/importing/queue_items_grouped_album.html b/views/importing/queue_items_grouped_album.html index 88b8bc36..0aca62f7 100644 --- a/views/importing/queue_items_grouped_album.html +++ b/views/importing/queue_items_grouped_album.html @@ -125,6 +125,50 @@

{{.Track.Title}}

+ + {{if eq .Type "duplicate"}} +
+ + +
+ + + {{else}} +
+ +
+ + {{end}}
{{if eq .Type "duplicate"}} diff --git a/views/importing/queue_items_grouped_artist.html b/views/importing/queue_items_grouped_artist.html index 682f243d..f95dd2cb 100644 --- a/views/importing/queue_items_grouped_artist.html +++ b/views/importing/queue_items_grouped_artist.html @@ -123,6 +123,50 @@

{{.Track.Title}}

+ + {{if eq .Type "duplicate"}} +
+ + +
+ + + {{else}} +
+ +
+ + {{end}}
{{if eq .Type "duplicate"}} diff --git a/views/library/track_overview_panel.html b/views/library/track_overview_panel.html index d09e15e1..5a1c3bca 100644 --- a/views/library/track_overview_panel.html +++ b/views/library/track_overview_panel.html @@ -16,10 +16,25 @@
- Album art
+ +
+ + + 0:00 + +
+
diff --git a/views/library/unified_search_list.html b/views/library/unified_search_list.html index c4e85361..c62d057f 100644 --- a/views/library/unified_search_list.html +++ b/views/library/unified_search_list.html @@ -38,7 +38,7 @@ {{range .Results}} {{if eq .Type "track"}} - @@ -65,7 +65,7 @@

{{.Trac Edit {{else if eq .Type "lyric_404"}} - @@ -83,7 +83,7 @@

{{.Trac No Lyrics {{else if eq .Type "failed_lyrics"}} - diff --git a/views/metrics/overview.html b/views/metrics/overview.html index e167cb17..d7dd90fa 100644 --- a/views/metrics/overview.html +++ b/views/metrics/overview.html @@ -71,7 +71,7 @@
{ container.innerHTML = '

Charts unavailable

ApexCharts loading failed

'; }); @@ -175,14 +175,14 @@ document.body.addEventListener('htmx:responseError', function(evt) { console.error('HTMX request failed:', evt.detail); const target = evt.detail.target; - if (target && target.getAttribute('hx-get')?.includes('/ui/metrics/charts/')) { + if (target && target.getAttribute('hx-get')?.includes('/metrics/charts/')) { target.innerHTML = '

Failed to load chart

Please try refreshing

'; } }); // Add refresh functionality for all charts function refreshAllCharts() { - const chartContainers = document.querySelectorAll('[hx-get*="/ui/metrics/charts/"]'); + const chartContainers = document.querySelectorAll('[hx-get*="/metrics/charts/"]'); chartContainers.forEach(container => { htmx.trigger(container, 'load'); }); diff --git a/views/partials/job_status.html b/views/partials/job_status.html index 8015bfc6..b06d96c9 100644 --- a/views/partials/job_status.html +++ b/views/partials/job_status.html @@ -2,7 +2,7 @@ we could do something like {{template "job_status" .}} in main.html, for example and we will be able to see them.--> {{define "job_status"}}
diff --git a/views/partials/main.html b/views/partials/main.html index e8af4a0e..81558c04 100644 --- a/views/partials/main.html +++ b/views/partials/main.html @@ -37,6 +37,8 @@ {{template "playlists/playlist" .}} {{else if eq .Section "download"}} {{template "sections/download" .}} + {{else if eq .Section "tag"}} + {{template "sections/tag" .}} {{else if .IsTagEdit}} {{template "sections/tag" .}} {{else if .CurrentDownloader}} diff --git a/views/partials/navbar.html b/views/partials/navbar.html index bcc1e8c5..f33184ca 100644 --- a/views/partials/navbar.html +++ b/views/partials/navbar.html @@ -23,7 +23,7 @@
-
+
@@ -61,7 +61,7 @@

{{.Playlist.Name}}

-
+

Tracks

{{if .Playlist.Tracks}} @@ -125,7 +125,7 @@

Tracks

No tracks in this playlist

Add tracks from your library to get started.

diff --git a/views/sections/library.html b/views/sections/library.html index a97d9423..3cc0ba0d 100644 --- a/views/sections/library.html +++ b/views/sections/library.html @@ -2,7 +2,7 @@

Music Library

-
-
+
diff --git a/views/sections/playlists.html b/views/sections/playlists.html index f8a033e2..1baffacd 100644 --- a/views/sections/playlists.html +++ b/views/sections/playlists.html @@ -1,5 +1,5 @@ -
-
+
+

Playlists

@@ -8,7 +8,7 @@

Playlists

class="cursor-pointer group inline-flex items-center px-3 py-2 rounded-md text-sm font-medium tracking-wider transition-all duration-300 ease-out-expo hover:-translate-y-0.5 bg-blue-500/10 backdrop-blur-md border border-blue-400/30 text-blue-600 dark:text-blue-300 shadow-lg shadow-blue-500/10 hover:shadow-blue-500/20"> -

YAML

-
+
diff --git a/views/sections/tag.html b/views/sections/tag.html index 81c9cb03..28a816bc 100644 --- a/views/sections/tag.html +++ b/views/sections/tag.html @@ -6,22 +6,22 @@

Tags

{{if .Track.ID}} - - Album Art {{end}} -
-
diff --git a/views/tag/edit_form.html b/views/tag/edit_form.html index f7df5251..0dfc2dc6 100644 --- a/views/tag/edit_form.html +++ b/views/tag/edit_form.html @@ -15,10 +15,10 @@
+ +
+ + Download + +
+ + + 0:00 + +
+
+
@@ -303,7 +324,7 @@
Bitrate: {{.Track.Bitrate}} kbps
{{end}} {{if .Track.ChromaprintFingerprint}} -
Fingerprint: (open in new tab)
+
Fingerprint: (open in new tab)
{{end}} {{if index .Track.Attributes "acoustid"}}
AcoustID: {{index .Track.Attributes "acoustid"}}
@@ -353,7 +374,7 @@