From 6e4eb0c337a6538220e52266bdcafa5e6662f75b Mon Sep 17 00:00:00 2001 From: Contre Date: Wed, 20 May 2026 23:17:43 +0200 Subject: [PATCH 01/25] chore(refactor): Standarized api and htmx handlers --- src/features/hosting/respond/respond.go | 29 ++++ src/features/library/handlers.go | 51 ++---- src/features/playlists/handlers.go | 221 ++++++++++++------------ src/features/playlists/routes.go | 27 +-- 4 files changed, 177 insertions(+), 151 deletions(-) create mode 100644 src/features/hosting/respond/respond.go diff --git a/src/features/hosting/respond/respond.go b/src/features/hosting/respond/respond.go new file mode 100644 index 00000000..9c757187 --- /dev/null +++ b/src/features/hosting/respond/respond.go @@ -0,0 +1,29 @@ +package respond + +import "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. +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) +} + +// Err responds with an error toast for HTMX requests or a JSON error body otherwise. +func Err(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}) +} + +// Ok responds with a success toast for HTMX requests or a JSON message otherwise. +func Ok(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}) +} diff --git a/src/features/library/handlers.go b/src/features/library/handlers.go index bfbc45b1..836d0730 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 @@ -455,10 +451,7 @@ 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" { + if c.Get("HX-Request") == "true" { return c.Render("library/unified_search_list", fiber.Map{ "Results": results, "Pagination": pagination, @@ -502,17 +495,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.Err(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.Err(c, fiber.StatusInternalServerError, "Failed to delete track") } - - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Track deleted successfully"}) + return respond.Ok(c, "Track deleted successfully") } // DeleteAlbum deletes an album from the library. @@ -521,17 +510,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.Err(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.Err(c, fiber.StatusInternalServerError, "Failed to delete album") } - - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Album deleted successfully"}) + return respond.Ok(c, "Album deleted successfully") } // DeleteArtist deletes an artist from the library. @@ -540,17 +525,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.Err(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.Err(c, fiber.StatusInternalServerError, "Failed to delete artist") } - - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Artist deleted successfully"}) + return respond.Ok(c, "Artist deleted successfully") } // RenderTrackOverviewPanel renders the floating track overview panel. diff --git a/src/features/playlists/handlers.go b/src/features/playlists/handlers.go index b1da668e..8368610e 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" ) @@ -26,169 +27,195 @@ func (h *Handler) RenderPlaylistsSection(c *fiber.Ctx) error { playlists, err := h.service.GetAllPlaylists(c.Context()) if err != nil { slog.Error("Error loading playlists", "error", err) - playlists = []*music.Playlist{} // Continue with empty list + playlists = []*music.Playlist{} } - 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. -func (h *Handler) GetPlaylist(c *fiber.Ctx) error { - slog.Debug("GetPlaylist handler called", "id", c.Params("id")) +// RenderPlaylist renders a single playlist page (HTML, HTMX-aware). +func (h *Handler) RenderPlaylist(c *fiber.Ctx) error { + slog.Debug("RenderPlaylist handler called", "id", c.Params("id")) playlist, err := h.service.GetPlaylist(c.Context(), c.Params("id")) if err != nil { slog.Error("Error loading playlist", "error", err, "id", c.Params("id")) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlist") + return respond.Err(c, fiber.StatusInternalServerError, "Failed to load playlist") } if playlist == nil { - return c.Status(fiber.StatusNotFound).SendString("Playlist not found") + return respond.Err(c, fiber.StatusNotFound, "Playlist not found") } data := 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) } +// GetAllPlaylists returns all playlists as JSON. +func (h *Handler) GetAllPlaylists(c *fiber.Ctx) error { + slog.Debug("GetAllPlaylists handler called") + + playlists, err := h.service.GetAllPlaylists(c.Context()) + if err != nil { + slog.Error("Error loading playlists", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"playlists": playlists}) +} + +// GetPlaylist returns a single playlist as JSON. +func (h *Handler) GetPlaylist(c *fiber.Ctx) error { + slog.Debug("GetPlaylist handler called", "id", c.Params("id")) + + playlist, err := h.service.GetPlaylist(c.Context(), c.Params("id")) + if err != nil { + slog.Error("Error loading playlist", "error", err, "id", c.Params("id")) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if playlist == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "playlist not found"}) + } + return c.JSON(playlist) +} + // CreatePlaylist handles creating a new playlist. func (h *Handler) CreatePlaylist(c *fiber.Ctx) error { slog.Debug("CreatePlaylist handler called") - name := c.FormValue("name") - description := c.FormValue("description") - - if name == "" { - return c.Status(fiber.StatusBadRequest).SendString("Playlist name is required") + var req struct { + Name string `json:"name" form:"name"` + Description string `json:"description" form:"description"` + } + if err := c.BodyParser(&req); err != nil { + return respond.Err(c, fiber.StatusBadRequest, "Invalid request body") + } + if req.Name == "" { + return respond.Err(c, fiber.StatusBadRequest, "Playlist name is required") } - _, err := h.service.CreatePlaylist(c.Context(), name, description) + playlist, err := h.service.CreatePlaylist(c.Context(), req.Name, req.Description) if err != nil { slog.Error("Error creating playlist", "error", err) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to create playlist") + return respond.Err(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"}) + if c.Get("HX-Request") == "true" { + return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist created successfully"}) + } + return c.Status(fiber.StatusCreated).JSON(playlist) } // UpdatePlaylist handles updating a playlist. func (h *Handler) UpdatePlaylist(c *fiber.Ctx) error { slog.Debug("UpdatePlaylist handler called", "id", c.Params("id")) - playlistID := c.Params("id") - name := c.FormValue("name") - description := c.FormValue("description") - - if name == "" { - return c.Status(fiber.StatusBadRequest).SendString("Playlist name is required") + var req struct { + Name string `json:"name" form:"name"` + Description string `json:"description" form:"description"` + } + if err := c.BodyParser(&req); err != nil { + return respond.Err(c, fiber.StatusBadRequest, "Invalid request body") + } + if req.Name == "" { + return respond.Err(c, fiber.StatusBadRequest, "Playlist name is required") } - playlist, err := h.service.GetPlaylist(c.Context(), playlistID) + playlist, err := h.service.GetPlaylist(c.Context(), c.Params("id")) if err != nil { - slog.Error("Error loading playlist for update", "error", err, "id", playlistID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlist") + slog.Error("Error loading playlist for update", "error", err, "id", c.Params("id")) + return respond.Err(c, fiber.StatusInternalServerError, "Failed to load playlist") } if playlist == nil { - return c.Status(fiber.StatusNotFound).SendString("Playlist not found") + return respond.Err(c, fiber.StatusNotFound, "Playlist not found") } - playlist.Name = name - playlist.Description = description + playlist.Name = req.Name + playlist.Description = req.Description - 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") + if err := h.service.UpdatePlaylist(c.Context(), playlist); err != nil { + slog.Error("Error updating playlist", "error", err, "id", c.Params("id")) + return respond.Err(c, fiber.StatusInternalServerError, "Failed to update playlist") } - - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist updated successfully"}) + return respond.Ok(c, "Playlist updated successfully") } // DeletePlaylist handles deleting a playlist. func (h *Handler) DeletePlaylist(c *fiber.Ctx) error { slog.Debug("DeletePlaylist handler called", "id", c.Params("id")) - err := h.service.DeletePlaylist(c.Context(), c.Params("id")) - if err != nil { + if err := h.service.DeletePlaylist(c.Context(), c.Params("id")); 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.Err(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.Ok(c, "Playlist deleted successfully") } // AddItemToPlaylist handles adding tracks, artists, or albums to a playlist. func (h *Handler) AddItemToPlaylist(c *fiber.Ctx) error { - playlistID := c.FormValue("playlist_id") - itemType := c.FormValue("item_type") - itemID := c.FormValue("item_id") + var req struct { + PlaylistID string `json:"playlist_id" form:"playlist_id"` + ItemType string `json:"item_type" form:"item_type"` + ItemID string `json:"item_id" form:"item_id"` + } + if err := c.BodyParser(&req); err != nil { + return respond.Err(c, fiber.StatusBadRequest, "Invalid request body") + } - slog.Debug("AddItemToPlaylist handler called", "playlistID", playlistID, "itemType", itemType, "itemID", itemID) + slog.Debug("AddItemToPlaylist handler called", "playlistID", req.PlaylistID, "itemType", req.ItemType, "itemID", req.ItemID) - 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") + if req.PlaylistID == "" || req.ItemType == "" || req.ItemID == "" { + slog.Error("AddItemToPlaylist: missing required parameters", "playlistID", req.PlaylistID, "itemType", req.ItemType, "itemID", req.ItemID) + return respond.Err(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") + if err := h.service.AddItemToPlaylist(c.Context(), req.PlaylistID, req.ItemType, req.ItemID); err != nil { + slog.Error("Error adding item to playlist", "error", err, "playlistID", req.PlaylistID, "itemType", req.ItemType, "itemID", req.ItemID) + return respond.Err(c, fiber.StatusInternalServerError, "Failed to add item to playlist") } - // Get item name for success message var itemName string - switch itemType { + switch req.ItemType { case "track": - if track, err := h.service.library.GetTrack(c.Context(), itemID); err == nil && track != nil { + if track, err := h.service.library.GetTrack(c.Context(), req.ItemID); err == nil && track != nil { itemName = track.Title } case "artist": - if artist, err := h.service.library.GetArtist(c.Context(), itemID); err == nil && artist != nil { + if artist, err := h.service.library.GetArtist(c.Context(), req.ItemID); err == nil && artist != nil { itemName = artist.Name } case "album": - if album, err := h.service.library.GetAlbum(c.Context(), itemID); err == nil && album != nil { + if album, err := h.service.library.GetAlbum(c.Context(), req.ItemID); err == nil && album != nil { itemName = album.Title } } - var successMsg string - switch itemType { + var msg string + switch req.ItemType { case "track": - successMsg = fmt.Sprintf("Track '%s' added to playlist", itemName) + msg = fmt.Sprintf("Track '%s' added to playlist", itemName) case "artist": - successMsg = fmt.Sprintf("All tracks by '%s' added to playlist", itemName) + msg = fmt.Sprintf("All tracks by '%s' added to playlist", itemName) case "album": - successMsg = fmt.Sprintf("All tracks from '%s' added to playlist", itemName) + msg = fmt.Sprintf("All tracks from '%s' added to playlist", itemName) default: - successMsg = "Item added to playlist" + msg = "Item added to playlist" } - slog.Info("Item successfully added to playlist", "playlistID", playlistID, "itemType", itemType, "itemID", itemID) + slog.Info("Item successfully added to playlist", "playlistID", req.PlaylistID, "itemType", req.ItemType, "itemID", req.ItemID) - // Trigger playlist refresh and return success toast c.Set("HX-Trigger", "playlistUpdated") - return c.Render("toast/toastOk", fiber.Map{"Msg": successMsg}) + return respond.Ok(c, msg) } // RemoveTrackFromPlaylist handles removing a track from a playlist. @@ -199,28 +226,25 @@ 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.Err(c, fiber.StatusBadRequest, "Playlist ID and Track ID are required") } - err := h.service.RemoveTrackFromPlaylist(c.Context(), playlistID, trackID) - if err != nil { + if err := h.service.RemoveTrackFromPlaylist(c.Context(), playlistID, trackID); 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.Err(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.Ok(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) } -// GetPlaylistsForItem returns playlists for adding tracks, artists, or albums. +// GetPlaylistsForItem returns the add-to-playlist modal for a given item. func (h *Handler) GetPlaylistsForItem(c *fiber.Ctx) error { itemType := c.Params("type") itemID := c.Params("id") @@ -230,66 +254,58 @@ func (h *Handler) GetPlaylistsForItem(c *fiber.Ctx) error { playlists, err := h.service.GetAllPlaylists(c.Context()) if err != nil { slog.Error("Error loading playlists", "error", err, "type", itemType, "id", itemID) - return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlists") + return respond.Err(c, fiber.StatusInternalServerError, "Failed to load playlists") } - // Get item name for display var itemName string switch itemType { case "track": track, err := h.service.library.GetTrack(c.Context(), itemID) if err != nil || track == nil { - return c.Status(fiber.StatusNotFound).SendString("Track not found") + return respond.Err(c, fiber.StatusNotFound, "Track not found") } itemName = track.Title case "artist": artist, err := h.service.library.GetArtist(c.Context(), itemID) if err != nil || artist == nil { - return c.Status(fiber.StatusNotFound).SendString("Artist not found") + return respond.Err(c, fiber.StatusNotFound, "Artist not found") } itemName = artist.Name case "album": album, err := h.service.library.GetAlbum(c.Context(), itemID) if err != nil || album == nil { - return c.Status(fiber.StatusNotFound).SendString("Album not found") + return respond.Err(c, fiber.StatusNotFound, "Album not found") } itemName = album.Title default: - return c.Status(fiber.StatusBadRequest).SendString("Invalid item type") + return respond.Err(c, fiber.StatusBadRequest, "Invalid item type") } - data := fiber.Map{ + return c.Render("playlists/add_to_playlist_modal", fiber.Map{ "Playlists": playlists, "ItemType": itemType, "ItemID": itemID, "ItemName": itemName, - } - - return c.Render("playlists/add_to_playlist_modal", data) + }) } // ExportM3U handles exporting a playlist to an M3U file. func (h *Handler) ExportM3U(c *fiber.Ctx) error { slog.Debug("ExportM3U handler called", "id", c.Params("id")) - playlistID := c.Params("id") - - // Get playlist - playlist, err := h.service.GetPlaylist(c.Context(), playlistID) + playlist, err := h.service.GetPlaylist(c.Context(), c.Params("id")) if err != nil { - slog.Error("Error loading playlist for export", "error", err, "id", playlistID) + slog.Error("Error loading playlist for export", "error", err, "id", c.Params("id")) return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlist") } if playlist == nil { return c.Status(fiber.StatusNotFound).SendString("Playlist not found") } - // Generate M3U content var builder strings.Builder builder.WriteString("#EXTM3U\n") for _, track := range playlist.Tracks { - // Write extended M3U info duration := track.Metadata.Duration artists := make([]string, len(track.Artists)) for i, ar := range track.Artists { @@ -298,19 +314,12 @@ func (h *Handler) ExportM3U(c *fiber.Ctx) error { } } artistStr := strings.Join(artists, ", ") - builder.WriteString(fmt.Sprintf("#EXTINF:%d,%s - %s\n", duration, artistStr, track.Title)) - - // Write file path builder.WriteString(track.Path + "\n") } - 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 c.SendString(builder.String()) } diff --git a/src/features/playlists/routes.go b/src/features/playlists/routes.go index d9fd018d..51c71915 100644 --- a/src/features/playlists/routes.go +++ b/src/features/playlists/routes.go @@ -8,18 +8,25 @@ import ( func RegisterRoutes(app *fiber.App, service *Service) { handler := NewHandler(service) + // UI routes — always return HTML (full page or HTMX partial) ui := app.Group("/ui") ui.Get("/playlists", handler.RenderPlaylistsSection) - ui.Get("/playlists/:id", handler.GetPlaylist) + ui.Get("/playlists/:id", handler.RenderPlaylist) - playlists := app.Group("/playlists") - playlists.Get("/create-modal", handler.GetPlaylistCreationModal) - playlists.Post("/", handler.CreatePlaylist) - playlists.Put("/:id", handler.UpdatePlaylist) - playlists.Delete("/:id", handler.DeletePlaylist) - playlists.Post("/items", handler.AddItemToPlaylist) - playlists.Delete("/:playlistId/tracks/:trackId", handler.RemoveTrackFromPlaylist) - playlists.Get("/:type/:id/playlists", handler.GetPlaylistsForItem) + // HTMX UI component routes (modals, fragments) — static segments first + api := app.Group("/playlists") + api.Get("/create-modal", handler.GetPlaylistCreationModal) + api.Get("/:type/:id/playlists", handler.GetPlaylistsForItem) + api.Get("/:id/export", handler.ExportM3U) - playlists.Get("/:id/export", handler.ExportM3U) + // API routes — always return JSON + api.Get("/", handler.GetAllPlaylists) + api.Get("/:id", handler.GetPlaylist) + + // Mutation routes — return JSON for API callers, toast for HTMX + api.Post("/", handler.CreatePlaylist) + api.Post("/items", handler.AddItemToPlaylist) + api.Put("/:id", handler.UpdatePlaylist) + api.Delete("/:id", handler.DeletePlaylist) + api.Delete("/:playlistId/tracks/:trackId", handler.RemoveTrackFromPlaylist) } From 850ace794dd0b029bd567e1c97d1ba5270516573 Mon Sep 17 00:00:00 2001 From: Contre Date: Wed, 20 May 2026 23:27:52 +0200 Subject: [PATCH 02/25] chore(refactor): Standarized api and htmx handlers.go --- src/features/config/handlers.go | 10 +- src/features/downloading/handlers.go | 467 ++++++--------------------- src/features/importing/handlers.go | 10 +- src/features/jobs/handlers.go | 39 +-- src/features/lyrics/handlers.go | 27 +- src/features/metadata/handlers.go | 65 ++-- src/features/metrics/handlers.go | 11 +- src/features/playlists/handlers.go | 211 ++++++------ src/features/playlists/routes.go | 27 +- src/features/reorganize/handlers.go | 21 +- src/features/ui/handlers.go | 22 +- 11 files changed, 253 insertions(+), 657 deletions(-) diff --git a/src/features/config/handlers.go b/src/features/config/handlers.go index 38b3d5dc..315fdc39 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. diff --git a/src/features/downloading/handlers.go b/src/features/downloading/handlers.go index ca7923d8..799c7dae 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,46 +50,22 @@ 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.Err(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.Err(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", - }) + return respond.Err(c, fiber.StatusInternalServerError, "Failed to search albums") } if c.Get("HX-Request") == "true" { return h.renderAlbumResults(c, albums, req.Downloader) } - return c.JSON(fiber.Map{ - "albums": albums, - }) + return c.JSON(fiber.Map{"albums": albums}) } // SearchTracks handles track search requests @@ -105,46 +74,22 @@ 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.Err(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.Err(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.Err(c, fiber.StatusInternalServerError, "Failed to search tracks") } if c.Get("HX-Request") == "true" { return h.renderTrackResults(c, tracks, req.Downloader) } - return c.JSON(fiber.Map{ - "tracks": tracks, - }) + return c.JSON(fiber.Map{"tracks": tracks}) } // Search handles general search requests @@ -153,124 +98,69 @@ 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.Err(c, fiber.StatusBadRequest, "Invalid request body") } - if req.Query == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Query parameter is required", - }) + return respond.Err(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", - }) - } - } + htmx := c.Get("HX-Request") == "true" - // 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.Err(c, fiber.StatusInternalServerError, "Failed to search albums") } - return c.JSON(fiber.Map{ - "albums": albums, - }) + if htmx { + return h.renderAlbumResults(c, albums, req.Downloader) + } + return c.JSON(fiber.Map{"albums": albums}) + 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.Err(c, fiber.StatusInternalServerError, "Failed to search tracks") } - return c.JSON(fiber.Map{ - "tracks": tracks, - }) + if htmx { + return h.renderTrackResults(c, tracks, req.Downloader) + } + return c.JSON(fiber.Map{"tracks": tracks}) + 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.Err(c, fiber.StatusInternalServerError, "Failed to search artists") } - return c.JSON(fiber.Map{ - "artists": artists, - }) + if htmx { + return h.renderArtistResults(c, artists, req.Downloader) + } + return c.JSON(fiber.Map{"artists": artists}) + 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.Err(c, fiber.StatusInternalServerError, "Failed to search links") + } + if htmx { + switch result.Type { + case "artist": + return h.renderArtistLinkResults(c, result.Artist, result.Albums, req.Downloader) + default: + return h.renderLinkResults(c, result.Tracks, req.Downloader) + } } return c.JSON(result) + default: - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "Invalid search type", - }) + return respond.Err(c, fiber.StatusBadRequest, "Invalid search type") } } @@ -284,7 +174,6 @@ func (h *Handler) renderAlbumResults(c *fiber.Ctx, albums []music.Album, downloa // 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] @@ -297,18 +186,14 @@ func (h *Handler) renderTrackResults(c *fiber.Ctx, tracks []music.Track, downloa // 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, @@ -338,31 +223,31 @@ type DownloadTrackRequest struct { TrackID string `json:"trackId" form:"trackId"` } +// 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 +} + // DownloadTrack handles track download requests func (h *Handler) DownloadTrack(c *fiber.Ctx) error { slog.Debug("DownloadTrack handler called") 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.Err(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.Err(c, fiber.StatusBadRequest, "Track ID is required") } downloader := strings.Clone(c.Query("downloader", "dummy")) @@ -371,40 +256,13 @@ 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", - }) + return respond.Err(c, fiber.StatusInternalServerError, "Failed to start track download") } if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{ - "Msg": "Track download started", - }) + return c.Render("toast/toastOk", fiber.Map{"Msg": "Track download started"}) } - 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 c.JSON(fiber.Map{"jobId": jobID, "message": "Download started"}) } // DownloadAlbum handles album download requests @@ -413,50 +271,23 @@ 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.Err(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.Err(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.Err(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.Render("toast/toastOk", fiber.Map{"Msg": "Album download started"}) } - return c.JSON(fiber.Map{ - "jobId": jobID, - "message": "Download started", - }) + return c.JSON(fiber.Map{"jobId": jobID, "message": "Download started"}) } // DownloadArtist handles artist download requests @@ -465,50 +296,23 @@ 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.Err(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.Err(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.Err(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.Render("toast/toastOk", fiber.Map{"Msg": "Artist download started"}) } - return c.JSON(fiber.Map{ - "jobId": jobID, - "message": "Download started", - }) + return c.JSON(fiber.Map{"jobId": jobID, "message": "Download started"}) } // DownloadTracks handles multiple track download requests @@ -517,28 +321,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.Err(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.Err(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 +336,13 @@ 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.Err(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.Render("toast/toastOk", fiber.Map{"Msg": "Tracks download started"}) } - return c.JSON(fiber.Map{ - "jobId": jobID, - "message": "Download started", - }) + return c.JSON(fiber.Map{"jobId": jobID, "message": "Download started"}) } // DownloadPlaylist handles playlist download requests @@ -578,39 +354,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.Err(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.Err(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.Err(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 +372,13 @@ 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.Err(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.Render("toast/toastOk", fiber.Map{"Msg": "Playlist '" + req.PlaylistName + "' download started"}) } - 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 c.JSON(fiber.Map{"jobId": jobID, "message": "Download started"}) } // GetAlbumTracks handles requests to get tracks from an album @@ -652,33 +387,26 @@ 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] @@ -691,7 +419,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,15 +430,12 @@ 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() @@ -723,13 +448,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() @@ -745,7 +466,7 @@ func (h *Handler) GetChartTracks(c *fiber.Ctx) error { "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] @@ -764,18 +485,14 @@ 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() @@ -803,9 +520,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/importing/handlers.go b/src/features/importing/handlers.go index f1001927..79703415 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. diff --git a/src/features/jobs/handlers.go b/src/features/jobs/handlers.go index b9e5e078..219ab01c 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,21 +37,12 @@ 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.Err(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.Render("toast/toastOk", fiber.Map{"Msg": fmt.Sprintf("Started %s job", jobType)}) } return c.JSON(fiber.Map{"job_id": jobID}) } @@ -198,19 +183,11 @@ func (h *Handler) HandleCleanupJobs(c *fiber.Ctx) error { } 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.Err(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.Ok(c, "Finished jobs cleared") } func (h *Handler) HandleActiveJob(c *fiber.Ctx) error { diff --git a/src/features/lyrics/handlers.go b/src/features/lyrics/handlers.go index c17daddc..4e86431e 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" ) @@ -354,31 +355,17 @@ func (h *Handler) StartLyricsAnalysis(c *fiber.Ctx) error { // 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.Render("toast/toastOk", fiber.Map{"Msg": "Lyrics analysis started successfully"}) } - - return c.Redirect("/ui/analyze/lyrics") + return c.JSON(fiber.Map{"job_id": jobID}) } // 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/metadata/handlers.go b/src/features/metadata/handlers.go index ba18e1cb..fb8c7fc1 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" ) @@ -290,7 +291,6 @@ 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, @@ -301,18 +301,17 @@ func (h *Handler) FetchFromProvider(c *fiber.Ctx) error { "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 c.Render("main", fiber.Map{ + "Track": track, + "IsTagEdit": true, + "Artists": artists, + "Albums": albums, + "FetchError": "err", + "ProviderColors": providerColors, + "SelectedAlbumArtistID": selectedAlbumArtistID, + "SelectedArtistIDs": selectedArtistIDs, + }) } // Use the first track from search results @@ -401,7 +400,6 @@ 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, @@ -412,18 +410,17 @@ func (h *Handler) FetchFromProvider(c *fiber.Ctx) error { "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, - }) } + 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 @@ -786,28 +783,14 @@ func (h *Handler) StartAcoustIDAnalysis(c *fiber.Ctx) error { // // 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.Render("toast/toastOk", fiber.Map{"Msg": "AcoustID analysis started successfully"}) } - - return c.Redirect("/ui/analyze/metadata") + return c.JSON(fiber.Map{"job_id": jobID}) } // 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/metrics/handlers.go b/src/features/metrics/handlers.go index a5041b38..b8ad3b16 100644 --- a/src/features/metrics/handlers.go +++ b/src/features/metrics/handlers.go @@ -2,7 +2,6 @@ package metrics import ( "log/slog" - "strings" "github.com/gofiber/fiber/v2" ) @@ -27,15 +26,9 @@ 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, - }) + if c.Get("HX-Request") == "true" { + return c.Render("metrics/overview", fiber.Map{"Metrics": metrics}) } - return c.JSON(metrics) } diff --git a/src/features/playlists/handlers.go b/src/features/playlists/handlers.go index 8368610e..24d2f069 100644 --- a/src/features/playlists/handlers.go +++ b/src/features/playlists/handlers.go @@ -27,7 +27,7 @@ func (h *Handler) RenderPlaylistsSection(c *fiber.Ctx) error { playlists, err := h.service.GetAllPlaylists(c.Context()) if err != nil { slog.Error("Error loading playlists", "error", err) - playlists = []*music.Playlist{} + playlists = []*music.Playlist{} // Continue with empty list } return respond.Section(c, "playlists", fiber.Map{ @@ -36,186 +36,155 @@ func (h *Handler) RenderPlaylistsSection(c *fiber.Ctx) error { }) } -// RenderPlaylist renders a single playlist page (HTML, HTMX-aware). -func (h *Handler) RenderPlaylist(c *fiber.Ctx) error { - slog.Debug("RenderPlaylist handler called", "id", c.Params("id")) +// GetPlaylist renders a single playlist page. +func (h *Handler) GetPlaylist(c *fiber.Ctx) error { + slog.Debug("GetPlaylist handler called", "id", c.Params("id")) playlist, err := h.service.GetPlaylist(c.Context(), c.Params("id")) if err != nil { slog.Error("Error loading playlist", "error", err, "id", c.Params("id")) - return respond.Err(c, fiber.StatusInternalServerError, "Failed to load playlist") + return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlist") } if playlist == nil { - return respond.Err(c, fiber.StatusNotFound, "Playlist not found") + return c.Status(fiber.StatusNotFound).SendString("Playlist not found") } data := 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) } -// GetAllPlaylists returns all playlists as JSON. -func (h *Handler) GetAllPlaylists(c *fiber.Ctx) error { - slog.Debug("GetAllPlaylists handler called") - - playlists, err := h.service.GetAllPlaylists(c.Context()) - if err != nil { - slog.Error("Error loading playlists", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - return c.JSON(fiber.Map{"playlists": playlists}) -} - -// GetPlaylist returns a single playlist as JSON. -func (h *Handler) GetPlaylist(c *fiber.Ctx) error { - slog.Debug("GetPlaylist handler called", "id", c.Params("id")) - - playlist, err := h.service.GetPlaylist(c.Context(), c.Params("id")) - if err != nil { - slog.Error("Error loading playlist", "error", err, "id", c.Params("id")) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - if playlist == nil { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "playlist not found"}) - } - return c.JSON(playlist) -} - // CreatePlaylist handles creating a new playlist. func (h *Handler) CreatePlaylist(c *fiber.Ctx) error { slog.Debug("CreatePlaylist handler called") - var req struct { - Name string `json:"name" form:"name"` - Description string `json:"description" form:"description"` - } - if err := c.BodyParser(&req); err != nil { - return respond.Err(c, fiber.StatusBadRequest, "Invalid request body") - } - if req.Name == "" { - return respond.Err(c, fiber.StatusBadRequest, "Playlist name is required") + name := c.FormValue("name") + description := c.FormValue("description") + + if name == "" { + return c.Status(fiber.StatusBadRequest).SendString("Playlist name is required") } - playlist, err := h.service.CreatePlaylist(c.Context(), req.Name, req.Description) + _, err := h.service.CreatePlaylist(c.Context(), name, description) if err != nil { slog.Error("Error creating playlist", "error", err) - return respond.Err(c, fiber.StatusInternalServerError, "Failed to create playlist") + return c.Status(fiber.StatusInternalServerError).SendString("Failed to create playlist") } + // Trigger playlist refresh and return success toast c.Set("HX-Trigger", "refreshPlaylists") - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist created successfully"}) - } - return c.Status(fiber.StatusCreated).JSON(playlist) + return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist created successfully"}) } // UpdatePlaylist handles updating a playlist. func (h *Handler) UpdatePlaylist(c *fiber.Ctx) error { slog.Debug("UpdatePlaylist handler called", "id", c.Params("id")) - var req struct { - Name string `json:"name" form:"name"` - Description string `json:"description" form:"description"` - } - if err := c.BodyParser(&req); err != nil { - return respond.Err(c, fiber.StatusBadRequest, "Invalid request body") - } - if req.Name == "" { - return respond.Err(c, fiber.StatusBadRequest, "Playlist name is required") + playlistID := c.Params("id") + name := c.FormValue("name") + description := c.FormValue("description") + + if name == "" { + return c.Status(fiber.StatusBadRequest).SendString("Playlist name is required") } - playlist, err := h.service.GetPlaylist(c.Context(), c.Params("id")) + playlist, err := h.service.GetPlaylist(c.Context(), playlistID) if err != nil { - slog.Error("Error loading playlist for update", "error", err, "id", c.Params("id")) - return respond.Err(c, fiber.StatusInternalServerError, "Failed to load playlist") + slog.Error("Error loading playlist for update", "error", err, "id", playlistID) + return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlist") } if playlist == nil { - return respond.Err(c, fiber.StatusNotFound, "Playlist not found") + return c.Status(fiber.StatusNotFound).SendString("Playlist not found") } - playlist.Name = req.Name - playlist.Description = req.Description + playlist.Name = name + playlist.Description = description - if err := h.service.UpdatePlaylist(c.Context(), playlist); err != nil { - slog.Error("Error updating playlist", "error", err, "id", c.Params("id")) - return respond.Err(c, fiber.StatusInternalServerError, "Failed to update playlist") + 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.Ok(c, "Playlist updated successfully") + + // Return success toast + return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist updated successfully"}) } // DeletePlaylist handles deleting a playlist. func (h *Handler) DeletePlaylist(c *fiber.Ctx) error { slog.Debug("DeletePlaylist handler called", "id", c.Params("id")) - if err := h.service.DeletePlaylist(c.Context(), c.Params("id")); err != nil { + err := h.service.DeletePlaylist(c.Context(), c.Params("id")) + if err != nil { slog.Error("Error deleting playlist", "error", err, "id", c.Params("id")) - return respond.Err(c, fiber.StatusInternalServerError, "Failed to delete playlist") + return c.Status(fiber.StatusInternalServerError).SendString("Failed to delete playlist") } + // Trigger playlist refresh and return success toast c.Set("HX-Trigger", "refreshPlaylists") - return respond.Ok(c, "Playlist deleted successfully") + return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist deleted successfully"}) } // AddItemToPlaylist handles adding tracks, artists, or albums to a playlist. func (h *Handler) AddItemToPlaylist(c *fiber.Ctx) error { - var req struct { - PlaylistID string `json:"playlist_id" form:"playlist_id"` - ItemType string `json:"item_type" form:"item_type"` - ItemID string `json:"item_id" form:"item_id"` - } - if err := c.BodyParser(&req); err != nil { - return respond.Err(c, fiber.StatusBadRequest, "Invalid request body") - } + playlistID := c.FormValue("playlist_id") + itemType := c.FormValue("item_type") + itemID := c.FormValue("item_id") - slog.Debug("AddItemToPlaylist handler called", "playlistID", req.PlaylistID, "itemType", req.ItemType, "itemID", req.ItemID) + slog.Debug("AddItemToPlaylist handler called", "playlistID", playlistID, "itemType", itemType, "itemID", itemID) - if req.PlaylistID == "" || req.ItemType == "" || req.ItemID == "" { - slog.Error("AddItemToPlaylist: missing required parameters", "playlistID", req.PlaylistID, "itemType", req.ItemType, "itemID", req.ItemID) - return respond.Err(c, fiber.StatusBadRequest, "Playlist ID, item type, and item ID are required") + 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") } - if err := h.service.AddItemToPlaylist(c.Context(), req.PlaylistID, req.ItemType, req.ItemID); err != nil { - slog.Error("Error adding item to playlist", "error", err, "playlistID", req.PlaylistID, "itemType", req.ItemType, "itemID", req.ItemID) - return respond.Err(c, fiber.StatusInternalServerError, "Failed to add item to playlist") + 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") } + // Get item name for success message var itemName string - switch req.ItemType { + switch itemType { case "track": - if track, err := h.service.library.GetTrack(c.Context(), req.ItemID); err == nil && track != nil { + if track, err := h.service.library.GetTrack(c.Context(), itemID); err == nil && track != nil { itemName = track.Title } case "artist": - if artist, err := h.service.library.GetArtist(c.Context(), req.ItemID); err == nil && artist != nil { + if artist, err := h.service.library.GetArtist(c.Context(), itemID); err == nil && artist != nil { itemName = artist.Name } case "album": - if album, err := h.service.library.GetAlbum(c.Context(), req.ItemID); err == nil && album != nil { + if album, err := h.service.library.GetAlbum(c.Context(), itemID); err == nil && album != nil { itemName = album.Title } } - var msg string - switch req.ItemType { + var successMsg string + switch itemType { case "track": - msg = fmt.Sprintf("Track '%s' added to playlist", itemName) + successMsg = fmt.Sprintf("Track '%s' added to playlist", itemName) case "artist": - msg = fmt.Sprintf("All tracks by '%s' added to playlist", itemName) + successMsg = fmt.Sprintf("All tracks by '%s' added to playlist", itemName) case "album": - msg = fmt.Sprintf("All tracks from '%s' added to playlist", itemName) + successMsg = fmt.Sprintf("All tracks from '%s' added to playlist", itemName) default: - msg = "Item added to playlist" + successMsg = "Item added to playlist" } - slog.Info("Item successfully added to playlist", "playlistID", req.PlaylistID, "itemType", req.ItemType, "itemID", req.ItemID) + 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 respond.Ok(c, msg) + return c.Render("toast/toastOk", fiber.Map{"Msg": successMsg}) } // RemoveTrackFromPlaylist handles removing a track from a playlist. @@ -226,25 +195,28 @@ func (h *Handler) RemoveTrackFromPlaylist(c *fiber.Ctx) error { trackID := c.Params("trackId") if playlistID == "" || trackID == "" { - return respond.Err(c, fiber.StatusBadRequest, "Playlist ID and Track ID are required") + return c.Status(fiber.StatusBadRequest).SendString("Playlist ID and Track ID are required") } - if err := h.service.RemoveTrackFromPlaylist(c.Context(), playlistID, trackID); err != nil { + 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 respond.Err(c, fiber.StatusInternalServerError, "Failed to remove track from playlist") + return c.Status(fiber.StatusInternalServerError).SendString("Failed to remove track from playlist") } + // Trigger playlist refresh and return success toast c.Set("HX-Trigger", "playlistUpdated") - return respond.Ok(c, "Track removed from playlist") + return c.Render("toast/toastOk", fiber.Map{"Msg": "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) } -// GetPlaylistsForItem returns the add-to-playlist modal for a given item. +// GetPlaylistsForItem returns playlists for adding tracks, artists, or albums. func (h *Handler) GetPlaylistsForItem(c *fiber.Ctx) error { itemType := c.Params("type") itemID := c.Params("id") @@ -254,58 +226,66 @@ func (h *Handler) GetPlaylistsForItem(c *fiber.Ctx) error { playlists, err := h.service.GetAllPlaylists(c.Context()) if err != nil { slog.Error("Error loading playlists", "error", err, "type", itemType, "id", itemID) - return respond.Err(c, fiber.StatusInternalServerError, "Failed to load playlists") + return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlists") } + // Get item name for display var itemName string switch itemType { case "track": track, err := h.service.library.GetTrack(c.Context(), itemID) if err != nil || track == nil { - return respond.Err(c, fiber.StatusNotFound, "Track not found") + return c.Status(fiber.StatusNotFound).SendString("Track not found") } itemName = track.Title case "artist": artist, err := h.service.library.GetArtist(c.Context(), itemID) if err != nil || artist == nil { - return respond.Err(c, fiber.StatusNotFound, "Artist not found") + return c.Status(fiber.StatusNotFound).SendString("Artist not found") } itemName = artist.Name case "album": album, err := h.service.library.GetAlbum(c.Context(), itemID) if err != nil || album == nil { - return respond.Err(c, fiber.StatusNotFound, "Album not found") + return c.Status(fiber.StatusNotFound).SendString("Album not found") } itemName = album.Title default: - return respond.Err(c, fiber.StatusBadRequest, "Invalid item type") + return c.Status(fiber.StatusBadRequest).SendString("Invalid item type") } - return c.Render("playlists/add_to_playlist_modal", fiber.Map{ + data := fiber.Map{ "Playlists": playlists, "ItemType": itemType, "ItemID": itemID, "ItemName": itemName, - }) + } + + return c.Render("playlists/add_to_playlist_modal", data) } // ExportM3U handles exporting a playlist to an M3U file. func (h *Handler) ExportM3U(c *fiber.Ctx) error { slog.Debug("ExportM3U handler called", "id", c.Params("id")) - playlist, err := h.service.GetPlaylist(c.Context(), c.Params("id")) + playlistID := c.Params("id") + + // Get playlist + playlist, err := h.service.GetPlaylist(c.Context(), playlistID) if err != nil { - slog.Error("Error loading playlist for export", "error", err, "id", c.Params("id")) + slog.Error("Error loading playlist for export", "error", err, "id", playlistID) return c.Status(fiber.StatusInternalServerError).SendString("Failed to load playlist") } if playlist == nil { return c.Status(fiber.StatusNotFound).SendString("Playlist not found") } + // Generate M3U content var builder strings.Builder builder.WriteString("#EXTM3U\n") for _, track := range playlist.Tracks { + // Write extended M3U info duration := track.Metadata.Duration artists := make([]string, len(track.Artists)) for i, ar := range track.Artists { @@ -314,12 +294,19 @@ func (h *Handler) ExportM3U(c *fiber.Ctx) error { } } artistStr := strings.Join(artists, ", ") + builder.WriteString(fmt.Sprintf("#EXTINF:%d,%s - %s\n", duration, artistStr, track.Title)) + + // Write file path builder.WriteString(track.Path + "\n") } + 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(builder.String()) + + return c.SendString(m3uContent) } diff --git a/src/features/playlists/routes.go b/src/features/playlists/routes.go index 51c71915..d9fd018d 100644 --- a/src/features/playlists/routes.go +++ b/src/features/playlists/routes.go @@ -8,25 +8,18 @@ import ( func RegisterRoutes(app *fiber.App, service *Service) { handler := NewHandler(service) - // UI routes — always return HTML (full page or HTMX partial) ui := app.Group("/ui") ui.Get("/playlists", handler.RenderPlaylistsSection) - ui.Get("/playlists/:id", handler.RenderPlaylist) + ui.Get("/playlists/:id", handler.GetPlaylist) - // HTMX UI component routes (modals, fragments) — static segments first - api := app.Group("/playlists") - api.Get("/create-modal", handler.GetPlaylistCreationModal) - api.Get("/:type/:id/playlists", handler.GetPlaylistsForItem) - api.Get("/:id/export", handler.ExportM3U) + playlists := app.Group("/playlists") + playlists.Get("/create-modal", handler.GetPlaylistCreationModal) + playlists.Post("/", handler.CreatePlaylist) + playlists.Put("/:id", handler.UpdatePlaylist) + playlists.Delete("/:id", handler.DeletePlaylist) + playlists.Post("/items", handler.AddItemToPlaylist) + playlists.Delete("/:playlistId/tracks/:trackId", handler.RemoveTrackFromPlaylist) + playlists.Get("/:type/:id/playlists", handler.GetPlaylistsForItem) - // API routes — always return JSON - api.Get("/", handler.GetAllPlaylists) - api.Get("/:id", handler.GetPlaylist) - - // Mutation routes — return JSON for API callers, toast for HTMX - api.Post("/", handler.CreatePlaylist) - api.Post("/items", handler.AddItemToPlaylist) - api.Put("/:id", handler.UpdatePlaylist) - api.Delete("/:id", handler.DeletePlaylist) - api.Delete("/:playlistId/tracks/:trackId", handler.RemoveTrackFromPlaylist) + playlists.Get("/:id/export", handler.ExportM3U) } diff --git a/src/features/reorganize/handlers.go b/src/features/reorganize/handlers.go index fadb0ab8..3f6164ff 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" ) @@ -37,29 +38,17 @@ func (h *Handler) StartReorganizeAnalysis(c *fiber.Ctx) 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.Render("toast/toastOk", fiber.Map{"Msg": "File reorganization started successfully"}) } - - return c.Redirect("/ui/analyze/files") + return c.JSON(fiber.Map{"job_id": jobID}) } // 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/ui/handlers.go b/src/features/ui/handlers.go index be023932..71645720 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,14 +23,7 @@ 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. @@ -41,15 +35,5 @@ func (h *Handler) GetQuickActionsCard(c *fiber.Ctx) error { // 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"}) } From 8177901f0ac6b0c5f7d02074399718861854c052 Mon Sep 17 00:00:00 2001 From: Contre Date: Thu, 21 May 2026 00:30:47 +0200 Subject: [PATCH 03/25] chore(refactor): Standarized text responses --- src/features/config/handlers.go | 2 +- src/features/downloading/handlers.go | 184 +++++++++--------------- src/features/hosting/respond/respond.go | 34 ++++- src/features/importing/handlers.go | 19 +-- src/features/jobs/handlers.go | 22 ++- src/features/library/handlers.go | 26 ++-- src/features/lyrics/handlers.go | 26 ++-- src/features/metadata/handlers.go | 36 ++--- src/features/metrics/handlers.go | 16 +-- src/features/playlists/handlers.go | 4 +- src/features/reorganize/handlers.go | 5 +- src/features/ui/handlers.go | 2 +- 12 files changed, 163 insertions(+), 213 deletions(-) diff --git a/src/features/config/handlers.go b/src/features/config/handlers.go index 315fdc39..ab71e34c 100644 --- a/src/features/config/handlers.go +++ b/src/features/config/handlers.go @@ -146,7 +146,7 @@ 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, }) } diff --git a/src/features/downloading/handlers.go b/src/features/downloading/handlers.go index 799c7dae..e3646931 100644 --- a/src/features/downloading/handlers.go +++ b/src/features/downloading/handlers.go @@ -61,11 +61,10 @@ func (h *Handler) SearchAlbums(c *fiber.Ctx) error { slog.Error("Failed to search albums", "error", err) return respond.Err(c, fiber.StatusInternalServerError, "Failed to search albums") } - - if c.Get("HX-Request") == "true" { - return h.renderAlbumResults(c, albums, req.Downloader) - } - return c.JSON(fiber.Map{"albums": albums}) + return respond.Partial(c, "downloading/album_results", fiber.Map{ + "Albums": albums, + "Downloader": req.Downloader, + }) } // SearchTracks handles track search requests @@ -86,10 +85,14 @@ func (h *Handler) SearchTracks(c *fiber.Ctx) error { return respond.Err(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, + }) } // Search handles general search requests @@ -107,8 +110,6 @@ func (h *Handler) Search(c *fiber.Ctx) error { req.Limit = 20 } - htmx := c.Get("HX-Request") == "true" - switch req.Type { case "album": albums, err := h.service.SearchAlbums(req.Downloader, req.Query, req.Limit) @@ -116,10 +117,10 @@ func (h *Handler) Search(c *fiber.Ctx) error { slog.Error("Failed to search albums", "error", err) return respond.Err(c, fiber.StatusInternalServerError, "Failed to search albums") } - if htmx { - return h.renderAlbumResults(c, albums, req.Downloader) - } - 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) @@ -127,10 +128,14 @@ func (h *Handler) Search(c *fiber.Ctx) error { slog.Error("Failed to search tracks", "error", err) return respond.Err(c, fiber.StatusInternalServerError, "Failed to search tracks") } - if htmx { - 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, + }) case "artist": artists, err := h.service.SearchArtists(req.Downloader, req.Query, req.Limit) @@ -138,10 +143,10 @@ func (h *Handler) Search(c *fiber.Ctx) error { slog.Error("Failed to search artists", "error", err) return respond.Err(c, fiber.StatusInternalServerError, "Failed to search artists") } - if htmx { - return h.renderArtistResults(c, artists, req.Downloader) - } - 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) @@ -149,75 +154,35 @@ func (h *Handler) Search(c *fiber.Ctx) error { slog.Error("Failed to search links", "error", err) return respond.Err(c, fiber.StatusInternalServerError, "Failed to search links") } - if htmx { - switch result.Type { - case "artist": - return h.renderArtistLinkResults(c, result.Artist, result.Albums, req.Downloader) - default: - return h.renderLinkResults(c, result.Tracks, req.Downloader) - } + 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, + }) + } + 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.JSON(result) + return c.Render("downloading/link_results", fiber.Map{ + "Tracks": trackPtrs, + "Downloader": req.Downloader, + "PlaylistName": playlistName, + }) default: return respond.Err(c, fiber.StatusBadRequest, "Invalid search type") } } -// 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 { - trackPtrs := make([]*music.Track, len(tracks)) - for i := range tracks { - trackPtrs[i] = &tracks[i] - } - 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 { - playlistName := "" - if len(tracks) > 0 && tracks[0].Attributes != nil { - playlistName = tracks[0].Attributes["playlist_name"] - } - 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, - }) -} - -// 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, - }) -} - -// 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, - }) -} - // DownloadTrackRequest represents a download track request type DownloadTrackRequest struct { TrackID string `json:"trackId" form:"trackId"` @@ -259,10 +224,7 @@ func (h *Handler) DownloadTrack(c *fiber.Ctx) error { return respond.Err(c, fiber.StatusInternalServerError, "Failed to start track download") } - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{"Msg": "Track download started"}) - } - return c.JSON(fiber.Map{"jobId": jobID, "message": "Download started"}) + return respond.Job(c, jobID, "Track download started") } // DownloadAlbum handles album download requests @@ -284,10 +246,7 @@ func (h *Handler) DownloadAlbum(c *fiber.Ctx) error { return respond.Err(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.Job(c, jobID, "Album download started") } // DownloadArtist handles artist download requests @@ -309,10 +268,7 @@ func (h *Handler) DownloadArtist(c *fiber.Ctx) error { return respond.Err(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.Job(c, jobID, "Artist download started") } // DownloadTracks handles multiple track download requests @@ -339,10 +295,7 @@ func (h *Handler) DownloadTracks(c *fiber.Ctx) error { return respond.Err(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.Job(c, jobID, "Tracks download started") } // DownloadPlaylist handles playlist download requests @@ -375,10 +328,7 @@ func (h *Handler) DownloadPlaylist(c *fiber.Ctx) error { return respond.Err(c, fiber.StatusInternalServerError, "Failed to start playlist download") } - if c.Get("HX-Request") == "true" { - return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist '" + req.PlaylistName + "' download started"}) - } - return c.JSON(fiber.Map{"jobId": jobID, "message": "Download started"}) + return respond.Job(c, jobID, "Playlist '"+req.PlaylistName+"' download started") } // GetAlbumTracks handles requests to get tracks from an album @@ -411,7 +361,7 @@ func (h *Handler) GetAlbumTracks(c *fiber.Ctx) error { 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, @@ -440,7 +390,7 @@ func (h *Handler) GetChartTracks(c *fiber.Ctx) error { 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, @@ -459,7 +409,7 @@ 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, @@ -498,20 +448,14 @@ func (h *Handler) GetUserInfo(c *fiber.Ctx) error { 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, }) } diff --git a/src/features/hosting/respond/respond.go b/src/features/hosting/respond/respond.go index 9c757187..7734f246 100644 --- a/src/features/hosting/respond/respond.go +++ b/src/features/hosting/respond/respond.go @@ -1,6 +1,10 @@ package respond -import "github.com/gofiber/fiber/v2" +import ( + "fmt" + + "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. @@ -27,3 +31,31 @@ func Ok(c *fiber.Ctx, msg string) error { } return c.JSON(fiber.Map{"message": msg}) } + +// Job responds with a success toast for HTMX requests or a 202 JSON body with the job_id otherwise. +func Job(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}) +} diff --git a/src/features/importing/handlers.go b/src/features/importing/handlers.go index 79703415..25f90be4 100644 --- a/src/features/importing/handlers.go +++ b/src/features/importing/handlers.go @@ -120,10 +120,11 @@ func (h *Handler) ProcessQueueItem(c *fiber.Ctx) error { // 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 @@ -201,7 +202,7 @@ func (h *Handler) ToggleWatcher(c *fiber.Ctx) error { // 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, }) } @@ -209,7 +210,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", @@ -225,7 +226,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(), }) @@ -259,7 +260,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, }) } @@ -272,7 +273,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, }) } @@ -383,7 +384,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/jobs/handlers.go b/src/features/jobs/handlers.go index 219ab01c..66ec50af 100644 --- a/src/features/jobs/handlers.go +++ b/src/features/jobs/handlers.go @@ -41,10 +41,7 @@ func (h *Handler) HandleStartJob(c *fiber.Ctx) error { } c.Set("HX-Trigger", "refreshActiveJobsBadge") - 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.Job(c, jobID, fmt.Sprintf("Started %s job", jobType)) } func (h *Handler) HandleJobStatus(c *fiber.Ctx) error { @@ -120,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, @@ -164,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, @@ -206,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, }) } @@ -231,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, }) } @@ -244,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, }) } @@ -262,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/library/handlers.go b/src/features/library/handlers.go index 836d0730..22523a10 100644 --- a/src/features/library/handlers.go +++ b/src/features/library/handlers.go @@ -135,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.Err(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. @@ -146,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.Err(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. @@ -157,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.Err(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. @@ -168,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.Err(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)) @@ -184,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. @@ -211,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, @@ -484,9 +482,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.Err(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. @@ -569,7 +567,7 @@ 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, diff --git a/src/features/lyrics/handlers.go b/src/features/lyrics/handlers.go index 4e86431e..e6824dec 100644 --- a/src/features/lyrics/handlers.go +++ b/src/features/lyrics/handlers.go @@ -80,7 +80,7 @@ func (h *Handler) RenderLyricsButtons(c *fiber.Ctx) error { 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(), }) @@ -122,7 +122,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. @@ -134,9 +134,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 @@ -170,7 +170,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, }) } @@ -210,10 +210,11 @@ func (h *Handler) ProcessLyricsQueueItem(c *fiber.Ctx) error { // 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 @@ -267,7 +268,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, }) @@ -317,7 +318,7 @@ func (h *Handler) ProcessLyricsQueueGroup(c *fiber.Ctx) error { 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, }) } @@ -355,10 +356,7 @@ func (h *Handler) StartLyricsAnalysis(c *fiber.Ctx) error { // 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.JSON(fiber.Map{"job_id": jobID}) + return respond.Job(c, jobID, "Lyrics analysis started successfully") } // RenderLyricsAnalysisSection renders the lyrics analysis section page diff --git a/src/features/metadata/handlers.go b/src/features/metadata/handlers.go index fb8c7fc1..b5f0ff37 100644 --- a/src/features/metadata/handlers.go +++ b/src/features/metadata/handlers.go @@ -423,14 +423,6 @@ func (h *Handler) FetchFromProvider(c *fiber.Ctx) error { }) } -// ModalData holds data for the search results modal -type ModalData struct { - Tracks []*music.Track - ProviderName string - ProviderColors map[string]string - TrackID string -} - // SearchTracksFromProvider handles searching for tracks from a specific provider func (h *Handler) SearchTracksFromProvider(c *fiber.Ctx) error { trackID := c.Params("trackId") @@ -452,12 +444,11 @@ func (h *Handler) SearchTracksFromProvider(c *fiber.Ctx) error { // 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, }) } @@ -593,8 +584,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.Partial(c, "sections/tag", fiber.Map{ "Track": mergedTrack, "Artists": artists, "Albums": albums, @@ -647,12 +637,9 @@ 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 @@ -669,7 +656,7 @@ func (h *Handler) RenderMetadataButtons(c *fiber.Ctx) error { 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(), }) @@ -783,10 +770,7 @@ func (h *Handler) StartAcoustIDAnalysis(c *fiber.Ctx) error { // // 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.JSON(fiber.Map{"job_id": jobID}) + return respond.Job(c, jobID, "AcoustID analysis started successfully") } // RenderMetadataAnalysisSection renders the metadata analysis section page diff --git a/src/features/metrics/handlers.go b/src/features/metrics/handlers.go index b8ad3b16..9a6dc304 100644 --- a/src/features/metrics/handlers.go +++ b/src/features/metrics/handlers.go @@ -3,6 +3,7 @@ package metrics import ( "log/slog" + "github.com/contre95/soulsolid/src/features/hosting/respond" "github.com/gofiber/fiber/v2" ) @@ -26,10 +27,7 @@ func (h *Handler) GetMetricsOverview(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).SendString("Error loading metrics") } - if c.Get("HX-Request") == "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. @@ -43,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, }) } @@ -59,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, }) } @@ -75,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, }) } @@ -91,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, }) } @@ -139,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/playlists/handlers.go b/src/features/playlists/handlers.go index 24d2f069..4e6644ab 100644 --- a/src/features/playlists/handlers.go +++ b/src/features/playlists/handlers.go @@ -213,7 +213,7 @@ func (h *Handler) RemoveTrackFromPlaylist(c *fiber.Ctx) error { 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. @@ -261,7 +261,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. diff --git a/src/features/reorganize/handlers.go b/src/features/reorganize/handlers.go index 3f6164ff..3b643b42 100644 --- a/src/features/reorganize/handlers.go +++ b/src/features/reorganize/handlers.go @@ -38,10 +38,7 @@ func (h *Handler) StartReorganizeAnalysis(c *fiber.Ctx) 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.JSON(fiber.Map{"job_id": jobID}) + return respond.Job(c, jobID, "File reorganization started successfully") } // RenderFilesReorganizationSection renders the file paths section page diff --git a/src/features/ui/handlers.go b/src/features/ui/handlers.go index 71645720..c4dfa77c 100644 --- a/src/features/ui/handlers.go +++ b/src/features/ui/handlers.go @@ -29,7 +29,7 @@ func (h *Handler) RenderDashboard(c *fiber.Ctx) error { // 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 From d0377ebedd2929c6df3cdd55bd6fbddc603b380a Mon Sep 17 00:00:00 2001 From: Contre Date: Thu, 21 May 2026 00:48:18 +0200 Subject: [PATCH 04/25] chore(refactor): Standarized others --- src/features/importing/handlers.go | 98 ++++++++--------------------- src/features/lyrics/handlers.go | 35 +++-------- src/features/metadata/handlers.go | 28 ++------- src/features/playlists/handlers.go | 37 +++++------ src/features/reorganize/handlers.go | 4 +- 5 files changed, 58 insertions(+), 144 deletions(-) diff --git a/src/features/importing/handlers.go b/src/features/importing/handlers.go index 25f90be4..5b070151 100644 --- a/src/features/importing/handlers.go +++ b/src/features/importing/handlers.go @@ -68,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.Err(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.Err(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.Job(c, jobID, "Directory import started!") } // ProcessQueueItem handles import/cancel actions for individual queue items @@ -95,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.Err(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to process queue item: %s", err.Error())) } - // Return success response that updates the UI actionMsg := "skipped" switch action { case "import": @@ -109,12 +98,8 @@ 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.Ok(c, fmt.Sprintf("Track %s successfully", actionMsg)) } // QueueCount returns the current queue count formatted as "(X)" or empty if 0 @@ -132,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.Err(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.Ok(c, "Queue cleared successfully") } // PruneDownloadPath handles pruning the download path and clearing the queue @@ -149,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.Err(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.Ok(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.Err(c, fiber.StatusBadRequest, "action parameter required") } var err error @@ -181,22 +152,16 @@ 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.Err(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.Err(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.Ok(c, msg) } // GetWatcherStatus returns the current status of the watcher @@ -293,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.Err(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.Err(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.Err(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to process group %s", decodedGroupKey)) } actionMsg := "processed" @@ -324,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.Ok(c, fmt.Sprintf("Group '%s' %s successfully", decodedGroupKey, actionMsg)) } // RenderGroupedQueueItems renders queue items grouped by artist or album diff --git a/src/features/lyrics/handlers.go b/src/features/lyrics/handlers.go index e6824dec..c6db9689 100644 --- a/src/features/lyrics/handlers.go +++ b/src/features/lyrics/handlers.go @@ -183,11 +183,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.Err(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": @@ -202,9 +199,7 @@ 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.Ok(c, fmt.Sprintf("Track %s successfully", actionMsg)) } // LyricsQueueCount returns the current lyrics queue count formatted as "(X)" or empty if 0 @@ -222,14 +217,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.Err(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.Ok(c, "Lyrics queue cleared successfully") } // RenderGroupedLyricsQueueItems renders lyrics queue items grouped by artist or album @@ -297,21 +288,17 @@ 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.Err(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.Ok(c, fmt.Sprintf("Group '%s' processed successfully", decodedGroupKey)) } // RenderLyricsQueueHeader renders the lyrics queue header for HTMX @@ -330,9 +317,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.Err(c, fiber.StatusBadRequest, "Please select a lyrics provider") } // Get options from form data @@ -347,9 +332,7 @@ 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.Err(c, fiber.StatusInternalServerError, "Failed to start lyrics analysis: "+err.Error()) } slog.Info("Lyrics analysis job started successfully", "jobID", jobID, "provider", provider) diff --git a/src/features/metadata/handlers.go b/src/features/metadata/handlers.go index b5f0ff37..bfd4c0b9 100644 --- a/src/features/metadata/handlers.go +++ b/src/features/metadata/handlers.go @@ -436,9 +436,7 @@ 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.Err(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to search tracks: %v", err)) } // Get provider colors for styling @@ -607,18 +605,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.Err(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.Ok(c, "Fingerprint calculated successfully!") } // ViewFingerprint handles viewing fingerprint @@ -743,15 +734,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.Err(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.Ok(c, "Tags updated successfully!") } // StartAcoustIDAnalysis handles starting the AcoustID analysis job @@ -761,9 +747,7 @@ 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.Err(c, fiber.StatusInternalServerError, "Failed to start AcoustID analysis: "+err.Error()) } slog.Info("AcoustID analysis job started successfully", "jobID", jobID) diff --git a/src/features/playlists/handlers.go b/src/features/playlists/handlers.go index 4e6644ab..e82f4f43 100644 --- a/src/features/playlists/handlers.go +++ b/src/features/playlists/handlers.go @@ -69,18 +69,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.Err(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.Err(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.Ok(c, "Playlist created successfully") } // UpdatePlaylist handles updating a playlist. @@ -92,16 +91,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.Err(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.Err(c, fiber.StatusInternalServerError, "Failed to load playlist") } if playlist == nil { - return c.Status(fiber.StatusNotFound).SendString("Playlist not found") + return respond.Err(c, fiber.StatusNotFound, "Playlist not found") } playlist.Name = name @@ -110,11 +109,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.Err(c, fiber.StatusInternalServerError, "Failed to update playlist") } - // Return success toast - return c.Render("toast/toastOk", fiber.Map{"Msg": "Playlist updated successfully"}) + return respond.Ok(c, "Playlist updated successfully") } // DeletePlaylist handles deleting a playlist. @@ -124,12 +122,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.Err(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.Ok(c, "Playlist deleted successfully") } // AddItemToPlaylist handles adding tracks, artists, or albums to a playlist. @@ -142,13 +139,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.Err(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.Err(c, fiber.StatusInternalServerError, "Failed to add item to playlist") } // Get item name for success message @@ -182,9 +179,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.Ok(c, successMsg) } // RemoveTrackFromPlaylist handles removing a track from a playlist. @@ -195,18 +191,17 @@ 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.Err(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.Err(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.Ok(c, "Track removed from playlist") } // GetPlaylistCreationModal returns the create playlist modal. diff --git a/src/features/reorganize/handlers.go b/src/features/reorganize/handlers.go index 3b643b42..7367047f 100644 --- a/src/features/reorganize/handlers.go +++ b/src/features/reorganize/handlers.go @@ -30,9 +30,7 @@ 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.Err(c, fiber.StatusInternalServerError, "Failed to start file reorganization job: "+err.Error()) } slog.Info("File reorganization job started", "jobID", jobID) From e31ac063a253d004f04c6170a5536c0cd5eb12ec Mon Sep 17 00:00:00 2001 From: Contre Date: Thu, 21 May 2026 00:49:50 +0200 Subject: [PATCH 05/25] chore(refactor): Remove duplicated route --- src/features/library/handlers.go | 108 ------------------------------ src/features/library/routes.go | 3 +- src/features/metadata/handlers.go | 16 +++-- src/features/metadata/routes.go | 31 +++------ 4 files changed, 23 insertions(+), 135 deletions(-) diff --git a/src/features/library/handlers.go b/src/features/library/handlers.go index 22523a10..061df1dc 100644 --- a/src/features/library/handlers.go +++ b/src/features/library/handlers.go @@ -574,111 +574,3 @@ func (h *Handler) RenderTrackOverviewPanel(c *fiber.Ctx) error { }) } -// 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..d485e45e 100644 --- a/src/features/library/routes.go +++ b/src/features/library/routes.go @@ -11,8 +11,7 @@ func RegisterRoutes(app *fiber.App, service *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) +ui.Get("/library/tracks/:trackId/overview", handler.RenderTrackOverviewPanel) library := app.Group("/library") library.Get("/search", handler.GetUnifiedSearch) diff --git a/src/features/metadata/handlers.go b/src/features/metadata/handlers.go index bfd4c0b9..ddae244a 100644 --- a/src/features/metadata/handlers.go +++ b/src/features/metadata/handlers.go @@ -26,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.Err(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.Err(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.Err(c, fiber.StatusInternalServerError, "Failed to load track data") } // Fetch all artists and albums for dropdowns diff --git a/src/features/metadata/routes.go b/src/features/metadata/routes.go index df57f8d1..51668350 100644 --- a/src/features/metadata/routes.go +++ b/src/features/metadata/routes.go @@ -10,30 +10,19 @@ func RegisterRoutes(app *fiber.App, service *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("/edit/:trackId", handler.RenderTagEditor) + tag.Get("/edit/:trackId/artwork", handler.ServeArtwork) + tag.Get("/edit/:trackId/fetch/:provider", handler.FetchFromProvider) + tag.Get("/edit/:trackId/search/:provider", handler.SearchTracksFromProvider) + tag.Get("/edit/:trackId/select/:provider", handler.SelectTrackFromResults) + tag.Get("/edit/:trackId/fingerprint", handler.CalculateFingerprint) + tag.Get("/edit/:trackId/fingerprint/view", handler.ViewFingerprint) + tag.Get("/buttons/metadata/:trackId", handler.RenderMetadataButtons) + tag.Post("/:trackId", handler.UpdateTags) - // 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) } From 9b1b7c429e4b62425e9dc540398dc0122590e3d9 Mon Sep 17 00:00:00 2001 From: Contre Date: Thu, 21 May 2026 00:50:14 +0200 Subject: [PATCH 06/25] chore(refactor): Edit duplicate route view --- src/features/lyrics/routes.go | 8 +++----- views/library/track_overview_panel.html | 2 +- views/library/unified_search_list.html | 2 +- views/lyrics/queue_items.html | 6 +++--- views/sections/tag.html | 8 ++++---- views/tag/edit_form.html | 4 ++-- views/tag/lyrics_buttons.html | 2 +- views/tag/metadata_buttons.html | 8 ++++---- views/tag/search_results_modal.html | 2 +- 9 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/features/lyrics/routes.go b/src/features/lyrics/routes.go index e15a2a62..33f9b79f 100644 --- a/src/features/lyrics/routes.go +++ b/src/features/lyrics/routes.go @@ -12,11 +12,9 @@ func RegisterRoutes(app *fiber.App, handler *Handler) { 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") - - // 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) + tag := app.Group("/tag") + tag.Get("/edit/:trackId/lyrics/text/:provider", handler.GetLyricsText) + tag.Get("/buttons/lyrics/:trackId", handler.RenderLyricsButtons) // Library routes for lyrics library := app.Group("/library") diff --git a/views/library/track_overview_panel.html b/views/library/track_overview_panel.html index d09e15e1..dbf0835d 100644 --- a/views/library/track_overview_panel.html +++ b/views/library/track_overview_panel.html @@ -16,7 +16,7 @@
- Album art
diff --git a/views/library/unified_search_list.html b/views/library/unified_search_list.html index c4e85361..ce13e3fa 100644 --- a/views/library/unified_search_list.html +++ b/views/library/unified_search_list.html @@ -82,7 +82,7 @@ - @@ -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/sections/tag.html b/views/sections/tag.html index 81c9cb03..a2560aee 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..6d026fa4 100644 --- a/views/tag/edit_form.html +++ b/views/tag/edit_form.html @@ -15,10 +15,10 @@

{{end}} - @@ -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/sections/tag.html b/views/sections/tag.html index a2560aee..beb98de6 100644 --- a/views/sections/tag.html +++ b/views/sections/tag.html @@ -6,10 +6,10 @@

Tags

{{if .Track.ID}} - - Album Art diff --git a/views/tag/edit_form.html b/views/tag/edit_form.html index 6d026fa4..995e1720 100644 --- a/views/tag/edit_form.html +++ b/views/tag/edit_form.html @@ -15,10 +15,10 @@

{{end}} @@ -24,7 +24,7 @@

{{.Playlist.Name}} -
+
@@ -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 beb98de6..28a816bc 100644 --- a/views/sections/tag.html +++ b/views/sections/tag.html @@ -16,12 +16,12 @@

Tags

{{end}} -
-
diff --git a/views/tag/edit_form.html b/views/tag/edit_form.html index 995e1720..e0903c53 100644 --- a/views/tag/edit_form.html +++ b/views/tag/edit_form.html @@ -353,7 +353,7 @@
+ + {{if eq .Type "duplicate"}} +
+ + +
+ + + {{else}} +
+ +
+ + {{end}} +

ID: {{.ID}}

diff --git a/views/importing/queue_items_grouped_album.html b/views/importing/queue_items_grouped_album.html index 88b8bc36..1fcbee2c 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..16733eba 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/partials/scripts.html b/views/partials/scripts.html index f294cfc0..ea35b331 100644 --- a/views/partials/scripts.html +++ b/views/partials/scripts.html @@ -139,6 +139,60 @@ document.getElementById('toast-container').appendChild(el); } + // Audio player controls + function toggleAudioPlayer(playBtn) { + var player = playBtn.closest('.audio-player'); + var audio = player.querySelector('audio'); + var icon = playBtn.querySelector('i'); + if (audio.paused) { + document.querySelectorAll('.audio-player audio').forEach(function(a) { + if (a !== audio && !a.paused) { + a.pause(); + var p = a.closest('.audio-player'); + if (p) { + var bi = p.querySelector('.play-btn i'); + if (bi) bi.className = 'fas fa-play fa-xs'; + } + } + }); + audio.play(); + icon.className = 'fas fa-pause fa-xs'; + } else { + audio.pause(); + icon.className = 'fas fa-play fa-xs'; + } + } + + function updateAudioProgress(audio) { + if (!audio.duration) return; + var player = audio.closest('.audio-player'); + if (!player) return; + var range = player.querySelector('input[type=range]'); + if (range) range.value = (audio.currentTime / audio.duration) * 100; + var timeEl = player.querySelector('.time-display'); + if (timeEl) { + var s = Math.floor(audio.currentTime); + timeEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); + } + } + + function seekAudioProgress(rangeEl) { + var player = rangeEl.closest('.audio-player'); + var audio = player.querySelector('audio'); + if (audio.duration) audio.currentTime = (rangeEl.value / 100) * audio.duration; + } + + function resetAudioPlayer(audio) { + var player = audio.closest('.audio-player'); + if (!player) return; + var bi = player.querySelector('.play-btn i'); + if (bi) bi.className = 'fas fa-play fa-xs'; + var range = player.querySelector('input[type=range]'); + if (range) range.value = 0; + var timeEl = player.querySelector('.time-display'); + if (timeEl) timeEl.textContent = '0:00'; + } + // Lyrics-specific grouping (matches queue_header.html IDs) function activateLyricsGroupingButton(groupingType) { const idMap = { From 6f45d6c11afa89a1ee6ecd5064eb239ae8d0eb75 Mon Sep 17 00:00:00 2001 From: Contre Date: Sat, 6 Jun 2026 10:41:20 +0200 Subject: [PATCH 13/25] fix: Reduce time of count refresh --- views/partials/navbar.html | 8 ++++---- views/partials/sidebar.html | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/views/partials/navbar.html b/views/partials/navbar.html index bcc1e8c5..10ddede2 100644 --- a/views/partials/navbar.html +++ b/views/partials/navbar.html @@ -58,7 +58,7 @@ Importing
@@ -97,7 +97,7 @@
@@ -136,7 +136,7 @@
@@ -196,7 +196,7 @@
diff --git a/views/partials/sidebar.html b/views/partials/sidebar.html index 7fc24e13..c8d7e641 100644 --- a/views/partials/sidebar.html +++ b/views/partials/sidebar.html @@ -51,7 +51,7 @@ Importing
@@ -114,7 +114,7 @@ Lyrics
@@ -165,7 +165,7 @@ All Jobs
From af4955d83eca6d81e3cf4185aa39f34195a259d4 Mon Sep 17 00:00:00 2001 From: Contre Date: Sat, 6 Jun 2026 10:49:25 +0200 Subject: [PATCH 14/25] feat: player on edit for + download --- views/importing/queue_header.html | 2 +- views/tag/edit_form.html | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/views/importing/queue_header.html b/views/importing/queue_header.html index 6e388a36..b264e584 100644 --- a/views/importing/queue_header.html +++ b/views/importing/queue_header.html @@ -10,7 +10,7 @@

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

diff --git a/views/tag/edit_form.html b/views/tag/edit_form.html index f7df5251..1c4df5df 100644 --- a/views/tag/edit_form.html +++ b/views/tag/edit_form.html @@ -114,6 +114,27 @@
+ +
+ + Download + +
+ + + 0:00 + +
+
+
From 37c4ebc76f0433d0c4a15fef6fdc5c5be2577044 Mon Sep 17 00:00:00 2001 From: Contre Date: Sat, 6 Jun 2026 10:53:08 +0200 Subject: [PATCH 15/25] feat: download {{.Track.Path}} --- views/tag/edit_form.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/tag/edit_form.html b/views/tag/edit_form.html index 1c4df5df..fb8e9641 100644 --- a/views/tag/edit_form.html +++ b/views/tag/edit_form.html @@ -116,7 +116,7 @@
- Download From 0b0992b421665da8dbf32345b19f93023de31f2a Mon Sep 17 00:00:00 2001 From: Contre Date: Sat, 6 Jun 2026 14:53:05 +0200 Subject: [PATCH 16/25] feat: replace dup q card text --- views/importing/queue_items.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/views/importing/queue_items.html b/views/importing/queue_items.html index df9a3464..3e4052f6 100644 --- a/views/importing/queue_items.html +++ b/views/importing/queue_items.html @@ -95,10 +95,10 @@

{{.Trac

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}}

From 4158e2bf769affcdabb3e5c282868205f481e68b Mon Sep 17 00:00:00 2001 From: Contre Date: Sat, 6 Jun 2026 15:03:05 +0200 Subject: [PATCH 17/25] fix: infinite indicator rendering --- views/library/track_overview_panel.html | 15 +++++++++++++++ views/partials/scripts.html | 3 --- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/views/library/track_overview_panel.html b/views/library/track_overview_panel.html index d09e15e1..e1a79dee 100644 --- a/views/library/track_overview_panel.html +++ b/views/library/track_overview_panel.html @@ -20,6 +20,21 @@ onerror="this.parentElement.style.display='none'">
+ +
+ + + 0:00 + +
+
diff --git a/views/partials/scripts.html b/views/partials/scripts.html index ea35b331..bcd90690 100644 --- a/views/partials/scripts.html +++ b/views/partials/scripts.html @@ -82,9 +82,6 @@ document.addEventListener("htmx:afterSettle", function (event) { // Re-process HTMX on queue content and main area for dynamic buttons if (event.target) { - if (event.target.id === "contenido" || event.target.id === "lyrics-queue-content" || event.target.id === "queue-content") { - htmx.process(event.target); - } if (event.target.id === "contenido") { initSlimSelect(); } From 899f036629e6063a2ffd8ddb84bb82240e3c5689 Mon Sep 17 00:00:00 2001 From: Contre Date: Sat, 6 Jun 2026 15:11:28 +0200 Subject: [PATCH 18/25] feat: Support symlinks in streaming feature --- src/features/streaming/service.go | 41 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/features/streaming/service.go b/src/features/streaming/service.go index 287d1aba..29deb3c9 100644 --- a/src/features/streaming/service.go +++ b/src/features/streaming/service.go @@ -9,6 +9,23 @@ import ( "github.com/contre95/soulsolid/src/features/config" ) +// containedIn resolves symlinks on both paths and checks that candidate is +// inside (or equal to) base, preventing symlink escapes. +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 from both the download folder and the library. type Service struct { queue QueueLocator @@ -27,12 +44,11 @@ func (s *Service) QueueTrackStream(itemID string) (string, string, error) { if err != nil { return "", "", fmt.Errorf("queue item not found: %w", err) } - downloadPath := filepath.Clean(s.cfg.Get().DownloadPath) - if !strings.HasPrefix(filepath.Clean(path), downloadPath+string(filepath.Separator)) && - filepath.Clean(path) != downloadPath { - return "", "", fmt.Errorf("track path outside allowed directory") + resolved, err := containedIn(path, s.cfg.Get().DownloadPath) + if err != nil { + return "", "", err } - return path, mimeTypeFor(path), nil + return resolved, mimeTypeFor(resolved), nil } // LibraryTrackStream returns the validated file path and MIME type for a library track. @@ -41,17 +57,18 @@ func (s *Service) LibraryTrackStream(ctx context.Context, trackID string) (strin if err != nil { return "", "", fmt.Errorf("track not found: %w", err) } - libraryPath := filepath.Clean(s.cfg.Get().LibraryPath) - if !strings.HasPrefix(filepath.Clean(path), libraryPath+string(filepath.Separator)) && - filepath.Clean(path) != libraryPath { - return "", "", fmt.Errorf("track path outside allowed directory") + resolved, err := containedIn(path, s.cfg.Get().LibraryPath) + if err != nil { + return "", "", err } - return path, mimeTypeFor(path), nil + return resolved, mimeTypeFor(resolved), nil } func mimeTypeFor(path string) string { - if strings.HasSuffix(strings.ToLower(path), ".flac") { + switch strings.ToLower(filepath.Ext(path)) { + case ".flac": return "audio/flac" + default: + return "audio/mpeg" } - return "audio/mpeg" } From 9b6c664fe37ec4c78c27fdb130dee91ebed484a9 Mon Sep 17 00:00:00 2001 From: Contre Date: Sat, 6 Jun 2026 15:16:01 +0200 Subject: [PATCH 19/25] fix: downlaod file name --- src/features/hosting/server.go | 2 ++ views/tag/edit_form.html | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/hosting/server.go b/src/features/hosting/server.go index bbcd071e..9fd8e106 100644 --- a/src/features/hosting/server.go +++ b/src/features/hosting/server.go @@ -4,6 +4,7 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "strings" "github.com/contre95/soulsolid/src/features/config" @@ -79,6 +80,7 @@ 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) app := fiber.New(fiber.Config{ Views: engine, diff --git a/views/tag/edit_form.html b/views/tag/edit_form.html index fb8e9641..1ed8f91a 100644 --- a/views/tag/edit_form.html +++ b/views/tag/edit_form.html @@ -116,7 +116,7 @@
- Download From 870d0413f4e09e881f4670b9ff4e66b2cb178ced Mon Sep 17 00:00:00 2001 From: Contre Date: Sat, 6 Jun 2026 16:27:08 +0200 Subject: [PATCH 20/25] fix: Remove missing ui endpoints --- FEATURE_SPEC_KIT.md | 6 +-- docs/api.md | 79 +++++++++++++++---------------- src/features/lyrics/lyrics_job.go | 2 +- 3 files changed, 42 insertions(+), 45 deletions(-) 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 index b549b639..56379bc9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,7 +1,9 @@ # SoulSolid API Reference -Every endpoint supports dual content negotiation via the `HX-Request` header. +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** @@ -22,11 +24,10 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API / Browser | |--------|-------|------|------|---------------| -| GET | `/` | — | redirect → `/ui` | redirect → `/ui` | -| GET | `/ui` | Section | `sections/dashboard` | full page | -| GET | `/ui/dashboard` | Section | `sections/dashboard` | full page | -| GET | `/ui/analyze` | Section | `sections/analyze` | full page | -| GET | `/ui/quick-actions-card` | Partial | HTML card | JSON data | +| 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 | --- @@ -34,9 +35,9 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API / Browser | |--------|-------|------|------|---------------| -| GET | `/ui/settings` | Section | `sections/settings` | full page | -| GET | `/ui/config/form` | Partial | HTML form | JSON config | -| POST | `/settings/update` | Toast OK | success toast | `{"message":"…"}` | +| 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":"…"}` | @@ -47,9 +48,9 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API | |--------|-------|------|------|-----| -| GET | `/ui/library` | Section | `sections/library` | full page | -| GET | `/ui/library/table` | Partial | HTML table | JSON data | -| GET | `/ui/library/tracks/:trackId/overview` | Partial | HTML panel | JSON data | +| 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}` | @@ -79,9 +80,8 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | 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 | -| GET | `/tag/buttons/metadata/:trackId` | Partial | HTML buttons | JSON data | | POST | `/analyze/acoustid` | Toast Job | success toast | `202 {"job_id":"…"}` | -| GET | `/ui/analyze/metadata` | Section | `sections/analyze_metadata` | full page | +| GET | `/analyze/metadata` | Section | `sections/analyze_metadata` | full page | --- @@ -89,11 +89,11 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API | |--------|-------|------|------|-----| -| GET | `/ui/import` | Section | `sections/import` | full page | -| GET | `/ui/importing/directory/form` | Partial | HTML form | JSON data | -| GET | `/ui/importing/queue/items` | Partial | HTML list | JSON items | -| GET | `/ui/importing/queue/items/grouped` | Partial | HTML grouped list | JSON groups | -| GET | `/ui/importing/queue/header` | Partial | HTML header | JSON data | +| 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":"…"}` | @@ -111,13 +111,13 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API | |--------|-------|------|------|-----| -| GET | `/ui/jobs` | Section | `sections/jobs` | full page | -| GET | `/ui/jobs/active` | Partial | HTML active list | JSON jobs | -| GET | `/ui/jobs/list` | Partial | HTML list | JSON jobs | -| GET | `/ui/jobs/latest` | Partial | HTML latest list | JSON jobs | -| GET | `/ui/jobs/count` | Text | `"(N)"` or `""` | `{"key":"jobs_count","value":N}` | -| POST | `/ui/jobs/clear-finished` | Toast OK | success toast | `{"message":"…"}` | -| GET | `/jobs/` | JSON | — | `[{job, _links}]` | +| 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 | @@ -131,8 +131,8 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API | |--------|-------|------|------|-----| -| GET | `/ui/download` | Section | `sections/download` | full page | -| GET | `/ui/downloading/chart/tracks` | Partial | HTML chart | JSON tracks | +| 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 | @@ -151,13 +151,10 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API | |--------|-------|------|------|-----| -| GET | `/ui/lyrics/queue/header` | Partial | HTML header | JSON data | -| GET | `/ui/lyrics/queue/items` | Partial | HTML list | JSON items | -| GET | `/ui/lyrics/queue/items/grouped` | Partial | HTML grouped list | JSON groups | -| GET | `/ui/analyze/lyrics` | Section | `sections/analyze_lyrics` | full page | -| GET | `/tag/buttons/lyrics/:trackId` | Partial | HTML buttons | JSON data | +| 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}` | @@ -173,8 +170,8 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API / Browser | |--------|-------|------|------|---------------| -| GET | `/ui/playlists` | Section | `sections/playlists` | full page | -| GET | `/ui/playlists/:id` | Partial | HTML playlist view | JSON playlist | +| 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":"…"}` | @@ -190,7 +187,7 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API | |--------|-------|------|------|-----| -| GET | `/ui/analyze/files` | Section | `sections/analyze_files` | full page | +| GET | `/analyze/files` | Section | `sections/analyze_files` | full page | | POST | `/analyze/reorganize` | Toast Job | success toast | `202 {"job_id":"…"}` | --- @@ -199,8 +196,8 @@ HTMX sends `HX-Request: true` automatically; anything else is treated as an API | Method | Route | Type | HTMX | API | |--------|-------|------|------|-----| -| GET | `/ui/metrics/overview` | Partial | HTML overview | JSON metrics | -| GET | `/ui/metrics/charts/genre` | Partial | HTML chart | JSON data | -| GET | `/ui/metrics/charts/year` | Partial | HTML chart | JSON data | -| GET | `/ui/metrics/charts/format` | Partial | HTML chart | JSON data | -| GET | `/ui/metrics/charts/metadata` | Partial | HTML chart | JSON data | +| 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 | 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 { From ce20247f466c5226e1d4a0f30da09ab893d2f10b Mon Sep 17 00:00:00 2001 From: Contre Date: Sun, 7 Jun 2026 13:03:06 +0200 Subject: [PATCH 21/25] fix: Remove dependencies --- src/features/hosting/server.go | 2 + src/features/streaming/handlers.go | 26 ++++------- src/features/streaming/routes.go | 3 +- src/features/streaming/service.go | 46 ++++++------------- src/features/streaming/streaming.go | 12 ----- src/main.go | 2 +- views/importing/queue_items.html | 6 +-- .../importing/queue_items_grouped_album.html | 6 +-- .../importing/queue_items_grouped_artist.html | 6 +-- views/library/track_overview_panel.html | 2 +- views/tag/edit_form.html | 4 +- 11 files changed, 40 insertions(+), 75 deletions(-) diff --git a/src/features/hosting/server.go b/src/features/hosting/server.go index 9fd8e106..b421a54c 100644 --- a/src/features/hosting/server.go +++ b/src/features/hosting/server.go @@ -3,6 +3,7 @@ package hosting import ( "fmt" "log/slog" + "net/url" "os" "path/filepath" "strings" @@ -81,6 +82,7 @@ func NewServer(cfg *config.Manager, importingService *importing.Service, library return strings.Title(strings.ToLower(s)) }) engine.AddFunc("pathBase", filepath.Base) + engine.AddFunc("urlEncode", url.QueryEscape) app := fiber.New(fiber.Config{ Views: engine, diff --git a/src/features/streaming/handlers.go b/src/features/streaming/handlers.go index 76f6c902..cfc8532f 100644 --- a/src/features/streaming/handlers.go +++ b/src/features/streaming/handlers.go @@ -2,6 +2,7 @@ package streaming import ( "log/slog" + "net/url" "github.com/gofiber/fiber/v2" ) @@ -16,28 +17,19 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// StreamQueueItem streams a pending track from the download folder. -func (h *Handler) StreamQueueItem(c *fiber.Ctx) error { - id := c.Params("id") - path, mimeType, err := h.service.QueueTrackStream(id) +// Stream serves the file at the given path query parameter. +func (h *Handler) Stream(c *fiber.Ctx) error { + rawPath := c.Query("path") + path, err := url.QueryUnescape(rawPath) if err != nil { - slog.Error("StreamQueueItem: failed to resolve path", "id", id, "error", err) - return c.Status(fiber.StatusNotFound).SendString("track not found") + return c.Status(fiber.StatusBadRequest).SendString("invalid path") } - c.Set("Content-Type", mimeType) - c.Set("Accept-Ranges", "bytes") - return c.SendFile(path) -} - -// StreamLibraryTrack streams an imported library track. -func (h *Handler) StreamLibraryTrack(c *fiber.Ctx) error { - id := c.Params("id") - path, mimeType, err := h.service.LibraryTrackStream(c.Context(), id) + resolved, mimeType, err := h.service.Stream(path) if err != nil { - slog.Error("StreamLibraryTrack: failed to resolve path", "id", id, "error", err) + 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(path) + return c.SendFile(resolved) } diff --git a/src/features/streaming/routes.go b/src/features/streaming/routes.go index ed7fed15..bb4a1294 100644 --- a/src/features/streaming/routes.go +++ b/src/features/streaming/routes.go @@ -5,6 +5,5 @@ 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/queue/:id", handler.StreamQueueItem) - app.Get("/stream/library/:id", handler.StreamLibraryTrack) + app.Get("/stream", handler.Stream) } diff --git a/src/features/streaming/service.go b/src/features/streaming/service.go index 29deb3c9..7dd855d1 100644 --- a/src/features/streaming/service.go +++ b/src/features/streaming/service.go @@ -1,7 +1,6 @@ package streaming import ( - "context" "fmt" "path/filepath" "strings" @@ -26,42 +25,27 @@ func containedIn(candidate, base string) (string, error) { return resolved, nil } -// Service handles audio streaming from both the download folder and the library. +// Service handles audio streaming by validating and serving file paths. type Service struct { - queue QueueLocator - library LibraryLocator - cfg *config.Manager + cfg *config.Manager } // NewService creates a new streaming service. -func NewService(queue QueueLocator, library LibraryLocator, cfg *config.Manager) *Service { - return &Service{queue: queue, library: library, cfg: cfg} +func NewService(cfg *config.Manager) *Service { + return &Service{cfg: cfg} } -// QueueTrackStream returns the validated file path and MIME type for a pending queue item. -func (s *Service) QueueTrackStream(itemID string) (string, string, error) { - path, err := s.queue.GetPendingTrackPath(itemID) - if err != nil { - return "", "", fmt.Errorf("queue item not found: %w", err) - } - resolved, err := containedIn(path, s.cfg.Get().DownloadPath) - if err != nil { - return "", "", err - } - return resolved, mimeTypeFor(resolved), nil -} - -// LibraryTrackStream returns the validated file path and MIME type for a library track. -func (s *Service) LibraryTrackStream(ctx context.Context, trackID string) (string, string, error) { - path, err := s.library.GetLibraryTrackPath(ctx, trackID) - if err != nil { - return "", "", fmt.Errorf("track not found: %w", err) - } - resolved, err := containedIn(path, s.cfg.Get().LibraryPath) - if err != nil { - return "", "", err - } - return resolved, mimeTypeFor(resolved), nil +// Stream validates that path is within the library or download directory 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 { + return resolved, mimeTypeFor(resolved), nil + } + } + return "", "", fmt.Errorf("track path outside allowed directories") } func mimeTypeFor(path string) string { diff --git a/src/features/streaming/streaming.go b/src/features/streaming/streaming.go index c456208a..05e985e3 100644 --- a/src/features/streaming/streaming.go +++ b/src/features/streaming/streaming.go @@ -1,13 +1 @@ package streaming - -import "context" - -// QueueLocator resolves a pending (not yet imported) track file path from the import queue. -type QueueLocator interface { - GetPendingTrackPath(itemID string) (string, error) -} - -// LibraryLocator resolves an imported track file path from the library. -type LibraryLocator interface { - GetLibraryTrackPath(ctx context.Context, trackID string) (string, error) -} diff --git a/src/main.go b/src/main.go index 7799eb0e..78cd1987 100644 --- a/src/main.go +++ b/src/main.go @@ -134,7 +134,7 @@ func main() { } } - streamingService := streaming.NewService(importingService, libraryService, cfgManager) + 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 { diff --git a/views/importing/queue_items.html b/views/importing/queue_items.html index 3e4052f6..585dffcf 100644 --- a/views/importing/queue_items.html +++ b/views/importing/queue_items.html @@ -63,7 +63,7 @@

{{.Trac 0:00 - +

{{else}}
@@ -86,7 +86,7 @@

{{.Trac 0:00 - +

{{end}} diff --git a/views/importing/queue_items_grouped_album.html b/views/importing/queue_items_grouped_album.html index 1fcbee2c..0aca62f7 100644 --- a/views/importing/queue_items_grouped_album.html +++ b/views/importing/queue_items_grouped_album.html @@ -143,7 +143,7 @@

{{.Track.Title}} 0:00 - +

{{else}}
@@ -166,7 +166,7 @@

{{.Track.Title}} 0:00 - +

{{end}} diff --git a/views/importing/queue_items_grouped_artist.html b/views/importing/queue_items_grouped_artist.html index 16733eba..f95dd2cb 100644 --- a/views/importing/queue_items_grouped_artist.html +++ b/views/importing/queue_items_grouped_artist.html @@ -141,7 +141,7 @@

{{.Track.Title}} 0:00 - +

{{else}}
@@ -164,7 +164,7 @@

{{.Track.Title}} 0:00 - +

{{end}} diff --git a/views/library/track_overview_panel.html b/views/library/track_overview_panel.html index e1a79dee..1a713732 100644 --- a/views/library/track_overview_panel.html +++ b/views/library/track_overview_panel.html @@ -30,7 +30,7 @@ class="flex-1 h-1 mx-1 accent-gray-500 cursor-pointer" oninput="seekAudioProgress(this)"> 0:00 -
diff --git a/views/tag/edit_form.html b/views/tag/edit_form.html index 1ed8f91a..7906a363 100644 --- a/views/tag/edit_form.html +++ b/views/tag/edit_form.html @@ -116,7 +116,7 @@
- Download @@ -129,7 +129,7 @@ class="flex-1 h-1 mx-1 accent-gray-500 cursor-pointer" oninput="seekAudioProgress(this)"> 0:00 -
From ea5d0a9117530c66db810d1d8678899ee88fb329 Mon Sep 17 00:00:00 2001 From: Contre Date: Sun, 7 Jun 2026 13:15:21 +0200 Subject: [PATCH 22/25] fix: refactor streaming service --- src/features/streaming/service.go | 5 +++-- src/features/streaming/streaming.go | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 src/features/streaming/streaming.go diff --git a/src/features/streaming/service.go b/src/features/streaming/service.go index 7dd855d1..a52dfca1 100644 --- a/src/features/streaming/service.go +++ b/src/features/streaming/service.go @@ -8,8 +8,9 @@ import ( "github.com/contre95/soulsolid/src/features/config" ) -// containedIn resolves symlinks on both paths and checks that candidate is -// inside (or equal to) base, preventing symlink escapes. +// 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 { diff --git a/src/features/streaming/streaming.go b/src/features/streaming/streaming.go deleted file mode 100644 index 05e985e3..00000000 --- a/src/features/streaming/streaming.go +++ /dev/null @@ -1 +0,0 @@ -package streaming From 240dbb6afd9caa0a6d421ba003dacefb733a30c2 Mon Sep 17 00:00:00 2001 From: Contre Date: Sun, 7 Jun 2026 13:51:44 +0200 Subject: [PATCH 23/25] fix: Audio mime types --- src/features/streaming/service.go | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/features/streaming/service.go b/src/features/streaming/service.go index a52dfca1..68146fa0 100644 --- a/src/features/streaming/service.go +++ b/src/features/streaming/service.go @@ -36,24 +36,31 @@ func NewService(cfg *config.Manager) *Service { return &Service{cfg: cfg} } -// Stream validates that path is within the library or download directory and -// returns the resolved path and MIME type. +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 { - return resolved, mimeTypeFor(resolved), 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") } - -func mimeTypeFor(path string) string { - switch strings.ToLower(filepath.Ext(path)) { - case ".flac": - return "audio/flac" - default: - return "audio/mpeg" - } -} From 0934fa1b4c9b0660cdbf109817be76f1d1bb67da Mon Sep 17 00:00:00 2001 From: Contre Date: Sun, 7 Jun 2026 14:11:21 +0200 Subject: [PATCH 24/25] fix: remove ui from sidebar+docs --- docs/api.md | 8 ++++++++ views/partials/sidebar.html | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 56379bc9..3d6d83d4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -201,3 +201,11 @@ Some endpoints are always JSON or HTMX-only and do not perform `HX-Request`-base | 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/views/partials/sidebar.html b/views/partials/sidebar.html index 30f5e28d..477c1336 100644 --- a/views/partials/sidebar.html +++ b/views/partials/sidebar.html @@ -164,7 +164,7 @@ d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" /> All Jobs -
Date: Sun, 7 Jun 2026 17:39:44 +0200 Subject: [PATCH 25/25] Code rabbit fixed --- src/features/hosting/respond/respond.go | 1 + src/features/importing/handlers.go | 2 +- src/features/streaming/handlers.go | 3 +++ views/partials/scripts.html | 7 +++++-- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/features/hosting/respond/respond.go b/src/features/hosting/respond/respond.go index 922e8c04..c3128e76 100644 --- a/src/features/hosting/respond/respond.go +++ b/src/features/hosting/respond/respond.go @@ -9,6 +9,7 @@ import ( // 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 diff --git a/src/features/importing/handlers.go b/src/features/importing/handlers.go index a3272000..133616b9 100644 --- a/src/features/importing/handlers.go +++ b/src/features/importing/handlers.go @@ -87,7 +87,7 @@ 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 respond.ToastErr(c, fiber.StatusInternalServerError, fmt.Sprintf("Failed to process queue item: %s", err.Error())) + return respond.ToastErr(c, fiber.StatusInternalServerError, "Failed to process queue item") } actionMsg := "skipped" switch action { diff --git a/src/features/streaming/handlers.go b/src/features/streaming/handlers.go index cfc8532f..8b739722 100644 --- a/src/features/streaming/handlers.go +++ b/src/features/streaming/handlers.go @@ -20,6 +20,9 @@ func NewHandler(service *Service) *Handler { // 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") diff --git a/views/partials/scripts.html b/views/partials/scripts.html index bcd90690..95149104 100644 --- a/views/partials/scripts.html +++ b/views/partials/scripts.html @@ -152,8 +152,11 @@ } } }); - audio.play(); - icon.className = 'fas fa-pause fa-xs'; + audio.play().then(function() { + icon.className = 'fas fa-pause fa-xs'; + }).catch(function(err) { + console.error('Audio playback failed:', err); + }); } else { audio.pause(); icon.className = 'fas fa-play fa-xs';