Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build:css:watch": "npx @tailwindcss/cli -i ./public/css/input.css -o ./public/css/style.css --watch",
"build:assets": "npm run build:css && npm run copy:deps",
"copy:deps": "mkdir -p public/js public/fontawesome && cp node_modules/htmx.org/dist/htmx.min.js public/js/ && cp node_modules/hyperscript.org/dist/_hyperscript.min.js public/js/ && cp node_modules/animate.css/animate.min.css public/css/ && cp -r node_modules/@fortawesome/fontawesome-free/css public/fontawesome/ && cp -r node_modules/@fortawesome/fontawesome-free/webfonts public/fontawesome/ && cp node_modules/@iconify/iconify/dist/iconify.min.js public/js/ && cp node_modules/jquery/dist/jquery.min.js public/js/ && cp node_modules/slim-select/dist/slimselect.js public/js/slimselect.min.js && cp node_modules/slim-select/dist/slimselect.css public/css/ && cp node_modules/apexcharts/dist/apexcharts.min.js public/js/",
"dev": "npm run build:assets && npm run build:css && go run ./src/main.go",
"dev": "npm run build:assets && npm run build:css && SOULSOLID_CONFIG_PATH=./config.yaml go run ./src/main.go",
"build": "npm run build:assets && npm run build:css",
"test": "echo \"Error: no test specified\" && exit 1"
},
Expand Down
227 changes: 227 additions & 0 deletions src/features/lyrics/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ package lyrics

import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"sort"
"time"

"github.com/contre95/soulsolid/src/music"
"github.com/gofiber/fiber/v2"
Expand Down Expand Up @@ -31,6 +36,35 @@ func NewHandler(service *Service, metadataService MetadataService) *Handler {
}
}

// queueItemView is a view model for queue items that includes the track
type queueItemView struct {
ID string
Type string
Timestamp time.Time
Track *music.Track
ItemMetadata map[string]string
}

// groupView is a view model for grouped queue items
type groupView struct {
Items []queueItemView
}

// convertQueueItem converts a music.QueueItem to queueItemView
func convertQueueItem(item music.QueueItem) (queueItemView, error) {
if item.Track == nil {
slog.Error("Queue item has no track", "itemID", item.ID, "type", item.Type)
return queueItemView{}, errors.New("queue item has no track")
}
return queueItemView{
ID: item.ID,
Type: string(item.Type),
Timestamp: item.Timestamp,
Track: item.Track,
ItemMetadata: item.Metadata,
}, nil
}

// RenderLyricsButtons renders the lyrics provider buttons for a track
func (h *Handler) RenderLyricsButtons(c *fiber.Ctx) error {
trackID := c.Params("trackId")
Expand Down Expand Up @@ -87,6 +121,199 @@ func (h *Handler) GetTrackLyrics(c *fiber.Ctx) error {
return c.SendString(track.Metadata.Lyrics)
}

// RenderLyricsQueueItems renders the lyrics queue content for HTMX
func (h *Handler) RenderLyricsQueueItems(c *fiber.Ctx) error {
slog.Debug("RenderLyricsQueueItems handler called")
queueItemsMap := h.service.GetLyricsQueueItems()
slog.Info("Lyrics queue items", "count", len(queueItemsMap))

// Collect all items into a slice of view models
queueItems := make([]queueItemView, 0, len(queueItemsMap))
for _, item := range queueItemsMap {
view, err := convertQueueItem(item)
if err != nil {
slog.Error("Failed to convert queue item", "error", err, "itemID", item.ID)
continue
}
queueItems = append(queueItems, view)
}
slog.Info("Successfully converted queue items", "converted", len(queueItems), "total", len(queueItemsMap))

// Sort by timestamp (oldest first)
sort.Slice(queueItems, func(i, j int) bool {
return queueItems[i].Timestamp.Before(queueItems[j].Timestamp)
})

// Limit to 10 items for better performance and UX
if len(queueItems) > 10 {
queueItems = queueItems[:10]
}
if len(queueItems) > 0 {
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{
"QueueItems": queueItems,
})
}

// ProcessLyricsQueueItem handles actions for individual lyrics queue items
func (h *Handler) ProcessLyricsQueueItem(c *fiber.Ctx) error {
itemID := c.Params("id")
action := c.Params("action")
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 success response that updates the UI
actionMsg := "processed"
switch action {
case "override":
actionMsg = "overridden"
case "keep_old":
actionMsg = "kept old"
case "edit_manual":
actionMsg = "marked for manual edit"
case "no_lyrics":
actionMsg = "marked as no lyrics"
case "skip":
actionMsg = "skipped"
}
c.Response().Header.Set("HX-Trigger", "lyricsQueueUpdated,refreshLyricsQueueBadge,updateLyricsQueueCount")
return c.Render("toast/toastOk", fiber.Map{
"Msg": fmt.Sprintf("Track %s successfully", actionMsg),
})
}

// LyricsQueueCount returns the current lyrics queue count formatted as "(X)" or empty if 0
func (h *Handler) LyricsQueueCount(c *fiber.Ctx) error {
count := len(h.service.GetLyricsQueueItems())
if count == 0 {
return c.SendString("")
}
return c.SendString(fmt.Sprintf("(%d)", count))
}

// ClearLyricsQueue handles clearing all items from the lyrics queue
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",
})
}
c.Response().Header.Set("HX-Trigger", "lyricsQueueUpdated,refreshLyricsQueueBadge,updateLyricsQueueCount")
return c.Render("toast/toastOk", fiber.Map{
"Msg": "Lyrics queue cleared successfully",
})
}

// RenderGroupedLyricsQueueItems renders lyrics queue items grouped by artist or album
func (h *Handler) RenderGroupedLyricsQueueItems(c *fiber.Ctx) error {
groupType := c.Query("type", "artist") // default to artist grouping

var groups map[string][]music.QueueItem
var templateName string

if groupType == "album" {
groups = h.service.GetLyricsGroupedByAlbum()
templateName = "lyrics/queue_items_grouped_album"
} else {
groups = h.service.GetLyricsGroupedByArtist()
templateName = "lyrics/queue_items_grouped_artist"
}

// Convert groups to view models
viewGroups := make(map[string]groupView)
for groupKey, items := range groups {
viewItems := make([]queueItemView, 0, len(items))
for _, item := range items {
view, err := convertQueueItem(item)
if err != nil {
slog.Error("Failed to convert queue item", "error", err, "itemID", item.ID)
continue
}
viewItems = append(viewItems, view)
}
// Sort items within each group by timestamp
sort.Slice(viewItems, func(i, j int) bool {
return viewItems[i].Timestamp.Before(viewItems[j].Timestamp)
})
viewGroups[groupKey] = groupView{
Items: viewItems,
}
}

return c.Render(templateName, fiber.Map{
"Groups": viewGroups,
"GroupType": groupType,
})
}

// ProcessLyricsQueueGroup processes all items in a group with the given action
func (h *Handler) ProcessLyricsQueueGroup(c *fiber.Ctx) error {
groupKey := c.Params("groupKey")
groupType := c.Params("groupType") // "artist" or "album"
action := c.Params("action")

// URL-decode the groupKey since it may contain encoded characters
decodedGroupKey, err := url.QueryUnescape(groupKey)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid groupKey encoding",
})
}

if groupType != "artist" && groupType != "album" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "groupType must be 'artist' or 'album'",
})
}

// Validate action based on queue item types (we'll need to implement group processing in service)
// For now, we'll process each item individually
var groupItems []music.QueueItem
if groupType == "artist" {
groups := h.service.GetLyricsGroupedByArtist()
groupItems = groups[decodedGroupKey]
} else {
groups := h.service.GetLyricsGroupedByAlbum()
groupItems = groups[decodedGroupKey]
}

if len(groupItems) == 0 {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "no items found in group",
})
}

// Process each item in the group
for _, item := range groupItems {
if err := h.service.ProcessLyricsQueueItem(c.Context(), item.ID, action); err != nil {
slog.Error("Failed to process lyrics queue item in group", "itemID", item.ID, "action", action, "error", err)
// Continue processing other items even if one fails
}
}

c.Response().Header.Set("HX-Trigger", "lyricsQueueUpdated,refreshLyricsQueueBadge,updateLyricsQueueCount")
return c.Render("toast/toastOk", fiber.Map{
"Msg": fmt.Sprintf("Group '%s' processed successfully", decodedGroupKey),
})
}

// RenderLyricsQueueHeader renders the lyrics queue header for HTMX
func (h *Handler) RenderLyricsQueueHeader(c *fiber.Ctx) error {
slog.Debug("RenderLyricsQueueHeader handler called")
count := len(h.service.GetLyricsQueueItems())
return c.Render("lyrics/queue_header", fiber.Map{
"QueueCount": count,
})
}

// StartLyricsAnalysis handles starting the lyrics analysis job
func (h *Handler) StartLyricsAnalysis(c *fiber.Ctx) error {
slog.Info("Starting lyrics analysis via HTTP request")
Expand Down
69 changes: 52 additions & 17 deletions src/features/lyrics/lyrics_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ func (t *LyricsJobTask) Execute(ctx context.Context, job *music.Job, progressUpd

job.Logger.Info("Enabled lyrics providers", "providers", enabledProviders, "color", "blue")

// Get initial queue state to track new items added during this job
initialQueueItems := t.service.GetLyricsQueueItems()
initialQueueIDs := make(map[string]bool)
for id := range initialQueueItems {
initialQueueIDs[id] = true
}

// Get total track count for progress reporting
totalTracks, err := t.service.libraryRepo.GetTracksCount(ctx)
if err != nil {
Expand Down Expand Up @@ -96,13 +103,6 @@ func (t *LyricsJobTask) Execute(ctx context.Context, job *music.Job, progressUpd
progress := (processed * 100) / totalTracks
progressUpdater(progress, fmt.Sprintf("Processing track %d/%d: %s", processed+1, totalTracks, track.Title))

// Skip tracks that already have lyrics
if track.Metadata.Lyrics != "" {
job.Logger.Info("Skipping track with existing lyrics", "trackID", track.ID, "title", track.Title, "lyricsLength", len(track.Metadata.Lyrics), "color", "orange")
skipped++
continue
}

// Get the specified provider from job metadata
provider, ok := job.Metadata["provider"].(string)
if !ok || provider == "" {
Expand All @@ -118,8 +118,17 @@ func (t *LyricsJobTask) Execute(ctx context.Context, job *music.Job, progressUpd
errors++
// Continue with other tracks - don't fail the entire job
} else {
updated++
job.Logger.Info("Successfully added lyrics for track", "trackID", track.ID, "title", track.Title, "provider", provider, "color", "green")
// Check if the track was added to the queue (existing lyrics, lyric 404, or failed lyrics queued earlier)
queueItems := t.service.GetLyricsQueueItems()
_, queued := queueItems[track.ID]
if queued && !initialQueueIDs[track.ID] {
// Track was queued (existing_lyrics or lyric_404), not counted as updated
// No increment to updated counter
} else {
// Lyrics were successfully added
updated++
job.Logger.Info("Successfully added lyrics for track", "trackID", track.ID, "title", track.Title, "provider", provider, "color", "green")
}
}

processed++
Expand All @@ -128,20 +137,46 @@ func (t *LyricsJobTask) Execute(ctx context.Context, job *music.Job, progressUpd

job.Logger.Info("Lyrics analysis completed", "totalTracks", totalTracks, "processed", processed, "updated", updated, "skipped", skipped, "color", "green")

// Count new queue items added during this job
finalQueueItems := t.service.GetLyricsQueueItems()
existingLyricsQueued := 0
lyric404Queued := 0
failedLyricsQueued := 0

for id, item := range finalQueueItems {
if !initialQueueIDs[id] {
switch item.Type {
case music.ExistingLyrics:
existingLyricsQueued++
case music.Lyric404:
lyric404Queued++
case music.FailedLyrics:
failedLyricsQueued++
}
}
}

// Create completion message for job tagging
finalMessage := fmt.Sprintf("Lyrics analysis finished. Processed %d tracks (%d updated, %d skipped, %d errors).",
totalTracks, updated, skipped, errors)
queueSummary := ""
if existingLyricsQueued > 0 || lyric404Queued > 0 || failedLyricsQueued > 0 {
queueSummary = fmt.Sprintf(" [Queue: %d existing_lyrics, %d lyric_404, %d failed_lyrics]", existingLyricsQueued, lyric404Queued, failedLyricsQueued)
}
finalMessage := fmt.Sprintf("Lyrics analysis finished. Processed %d tracks (%d updated, %d skipped, %d errors).%s",
totalTracks, updated, skipped, errors, queueSummary)
job.Logger.Info(finalMessage)

progressUpdater(100, fmt.Sprintf("Lyrics analysis completed - totalTracks=%d processed=%d updated=%d skipped=%d errors=%d", totalTracks, processed, updated, skipped, errors))

return map[string]any{
"totalTracks": totalTracks,
"processed": processed,
"updated": updated,
"skipped": skipped,
"errors": errors,
"msg": finalMessage,
"totalTracks": totalTracks,
"processed": processed,
"updated": updated,
"skipped": skipped,
"errors": errors,
"existingLyricsQueued": existingLyricsQueued,
"lyric404Queued": lyric404Queued,
"failedLyricsQueued": failedLyricsQueued,
"msg": finalMessage,
}, nil
}

Expand Down
11 changes: 11 additions & 0 deletions src/features/lyrics/queue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package lyrics

import "github.com/contre95/soulsolid/src/music"

// QueueItemType represents the type of item in the lyrics queue

const (
ExistingLyrics music.QueueItemType = "existing_lyrics"
Lyric404 music.QueueItemType = "lyric_404"
FailedLyrics music.QueueItemType = "failed_lyrics"
)
13 changes: 13 additions & 0 deletions src/features/lyrics/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import (
func RegisterRoutes(app *fiber.App, handler *Handler) {
// UI routes for HTMX partials
ui := app.Group("/ui")
// Lyrics queue UI routes
ui.Get("/lyrics/queue/header", handler.RenderLyricsQueueHeader)
ui.Get("/lyrics/queue/items", handler.RenderLyricsQueueItems)
ui.Get("/lyrics/queue/items/grouped", handler.RenderGroupedLyricsQueueItems)
tagGroup := ui.Group("/tag")

// Lyrics routes - these are accessed from the metadata/tag UI
Expand All @@ -18,6 +22,15 @@ func RegisterRoutes(app *fiber.App, handler *Handler) {
library := app.Group("/library")
library.Get("/tracks/:id/lyrics", handler.GetTrackLyrics)

// Lyrics queue routes
queue := app.Group("/lyrics/queue")
queue.Get("/items", handler.RenderLyricsQueueItems)
queue.Get("/items/grouped", handler.RenderGroupedLyricsQueueItems)
queue.Post("/:id/:action", handler.ProcessLyricsQueueItem)
queue.Post("/group/:groupType/:groupKey/:action", handler.ProcessLyricsQueueGroup)
queue.Post("/clear", handler.ClearLyricsQueue)
queue.Get("/count", handler.LyricsQueueCount)

// Analyze routes - lyrics analysis
analyze := app.Group("/analyze")
analyze.Post("/lyrics", handler.StartLyricsAnalysis)
Expand Down
Loading