user can configure visible columns in track-list

This commit is contained in:
2026-02-22 11:24:19 -05:00
parent 9d84a11543
commit b5e70a8ce6
15 changed files with 1137 additions and 82 deletions
+85 -6
View File
@@ -16,16 +16,18 @@ import (
"yellowjacket/backend/library"
"yellowjacket/backend/system"
"yellowjacket/backend/theme"
"yellowjacket/backend/tracklist"
)
// Config represents the application configuration.
type Config struct {
ctx context.Context
logger *slog.Logger
filePath string // required
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
Window *WindowConfig `toml:"Window"`
ctx context.Context
logger *slog.Logger
filePath string // required
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
Window *WindowConfig `toml:"Window"`
TrackList *tracklist.Config `toml:"TrackList"`
}
// NewConfig creates a new config by loading it from disk.
@@ -70,6 +72,12 @@ func (c *Config) Validate() error {
}
}
if c.TrackList != nil {
if err := c.TrackList.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if configErrs != nil {
return fmt.Errorf(
"one or more config parts are invalid: %w",
@@ -160,6 +168,12 @@ func (c *Config) applyDefaults() {
}
c.Theme.ApplyDefaults()
if c.TrackList == nil {
c.TrackList = &tracklist.Config{}
}
c.TrackList.ApplyDefaults()
}
// SetContext sets the Wails runtime context for event emission.
@@ -357,3 +371,68 @@ func (c *Config) emitThemeChanged() {
},
)
}
// GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
return tracklist.DefaultColumns
}
return c.TrackList.Columns
}
// SetTrackListColumns validates and saves a new column layout.
func (c *Config) SetTrackListColumns(
columns []tracklist.Column,
) error {
if c.TrackList == nil {
c.TrackList = &tracklist.Config{}
}
c.TrackList.Columns = columns
if err := c.TrackList.Validate(); err != nil {
return fmt.Errorf(
"invalid track-list columns: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitTrackListChanged()
c.logger.Info(
"track-list columns updated",
"count", len(columns),
)
return nil
}
// emitTrackListChanged sends the TrackListConfigChanged event
// to the frontend.
func (c *Config) emitTrackListChanged() {
if c.ctx == nil || c.TrackList == nil {
return
}
cols := make([]map[string]any, 0, len(c.TrackList.Columns))
for _, col := range c.TrackList.Columns {
cols = append(cols, map[string]any{
"id": string(col.ID),
})
}
runtime.EventsEmit(
c.ctx,
events.TrackListConfigChanged,
map[string]any{
"columns": cols,
},
)
}
@@ -71,6 +71,26 @@ LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.file_path = ?
LIMIT 1;
-- name: GetAllTracksWithFullMetadata :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
COALESCE(r.genre, '') AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
-- name: DeleteAllAudioFiles :exec
DELETE FROM audio_files;
@@ -190,6 +190,76 @@ func (q *Queries) GetAllAudioFilesWithArtist(ctx context.Context) ([]GetAllAudio
return items, nil
}
const getAllTracksWithFullMetadata = `-- name: GetAllTracksWithFullMetadata :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
COALESCE(r.genre, '') AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`
type GetAllTracksWithFullMetadataRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
}
func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTracksWithFullMetadataRow, error) {
rows, err := q.db.QueryContext(ctx, getAllTracksWithFullMetadata)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllTracksWithFullMetadataRow
for rows.Next() {
var i GetAllTracksWithFullMetadataRow
if err := rows.Scan(
&i.FilePath,
&i.LengthMilliseconds,
&i.Title,
&i.ArtistName,
&i.TrackNumber,
&i.DiscNumber,
&i.Album,
&i.Genre,
&i.Year,
&i.Composer,
&i.FileType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAudioFile = `-- name: GetAudioFile :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
WHERE id = ? LIMIT 1
+3 -2
View File
@@ -54,8 +54,9 @@ const (
// Config events.
const (
LibraryConfigChanged = "LibraryConfigChanged"
ThemeConfigChanged = "ThemeConfigChanged"
LibraryConfigChanged = "LibraryConfigChanged"
ThemeConfigChanged = "ThemeConfigChanged"
TrackListConfigChanged = "TrackListConfigChanged"
)
// Playlist events.
+33 -13
View File
@@ -21,6 +21,11 @@ type Track struct {
FilePath string
TrackNumber int64
DiscNumber int64
Album string
Genre string
Year int64
Composer string
FileType string
}
// Album represents an album for the cover grid display.
@@ -37,36 +42,51 @@ type Album struct {
// GetAllTracks returns an array of track structs of every file in the library.
func (l *Library) GetAllTracks() ([]Track, error) {
audioFiles, err := l.db.Queries.GetAllAudioFilesWithArtist(l.ctx)
rows, err := l.db.Queries.GetAllTracksWithFullMetadata(
l.ctx,
)
if err != nil {
l.logger.Error("could not retrieve audio files", "error", err)
l.logger.Error(
"could not retrieve audio files",
"error", err,
)
return nil, err
}
l.logger.Info("audio file list", "count", len(audioFiles))
l.logger.Info("audio file list", "count", len(rows))
if len(audioFiles) == 0 {
if len(rows) == 0 {
l.logger.Error("no tracks in library")
return nil, errNoTracksInLibrary
}
var formattedTracks []Track
tracks := make([]Track, 0, len(rows))
for _, file := range audioFiles {
for _, row := range rows {
track := Track{
TrackName: file.Title,
ArtistName: file.ArtistName,
TrackLength: strconv.FormatInt(file.LengthMilliseconds, 10),
FilePath: file.FilePath,
TrackName: row.Title,
ArtistName: row.ArtistName,
TrackLength: strconv.FormatInt(
row.LengthMilliseconds, 10,
),
FilePath: row.FilePath,
TrackNumber: row.TrackNumber.Int64,
DiscNumber: row.DiscNumber.Int64,
Album: row.Album,
Genre: row.Genre,
Year: row.Year,
Composer: row.Composer,
FileType: row.FileType,
}
formattedTracks = append(formattedTracks, track)
tracks = append(tracks, track)
}
l.logger.Info("formatted tracks", "count", len(formattedTracks))
l.logger.Info("formatted tracks", "count", len(tracks))
return formattedTracks, nil
return tracks, nil
}
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
+104
View File
@@ -0,0 +1,104 @@
// Package tracklist manages track-list display configuration.
package tracklist
import (
"errors"
"fmt"
"slices"
)
var (
errUnknownColumnID = errors.New("unknown track-list column ID")
errDuplicateColumn = errors.New("duplicate column ID")
)
// ColumnID identifies a displayable column in the track list.
type ColumnID string
// Valid column identifiers.
const (
ColTrackName ColumnID = "trackName"
ColArtistName ColumnID = "artistName"
ColTrackLength ColumnID = "trackLength"
ColAlbum ColumnID = "album"
ColGenre ColumnID = "genre"
ColYear ColumnID = "year"
ColComposer ColumnID = "composer"
ColTrackNumber ColumnID = "trackNumber"
ColDiscNumber ColumnID = "discNumber"
ColFilePath ColumnID = "filePath"
ColFileType ColumnID = "fileType"
)
// AllColumnIDs lists every recognised column in default display
// order.
var AllColumnIDs = []ColumnID{
ColTrackName,
ColArtistName,
ColTrackLength,
ColAlbum,
ColGenre,
ColYear,
ColComposer,
ColTrackNumber,
ColDiscNumber,
ColFilePath,
ColFileType,
}
// DefaultColumns is the initial column configuration matching the
// original hardcoded layout.
var DefaultColumns = []Column{
{ID: ColTrackName},
{ID: ColArtistName},
{ID: ColTrackLength},
}
// Column represents a visible column in the track list.
type Column struct {
ID ColumnID `json:"id" toml:"ID"`
}
// Config holds track-list display preferences.
type Config struct {
Columns []Column `json:"columns" toml:"Columns"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
func (c *Config) ApplyDefaults() {
if len(c.Columns) == 0 {
c.Columns = make([]Column, len(DefaultColumns))
copy(c.Columns, DefaultColumns)
}
}
// Validate checks that every column ID is recognised and that
// there are no duplicates.
func (c *Config) Validate() error {
c.ApplyDefaults()
seen := make(map[ColumnID]bool, len(c.Columns))
for _, col := range c.Columns {
if !isValidColumnID(col.ID) {
return fmt.Errorf(
"%w: %q", errUnknownColumnID, col.ID,
)
}
if seen[col.ID] {
return fmt.Errorf(
"%w: %q", errDuplicateColumn, col.ID,
)
}
seen[col.ID] = true
}
return nil
}
// isValidColumnID returns true when id matches a known column.
func isValidColumnID(id ColumnID) bool {
return slices.Contains(AllColumnIDs, id)
}