user can configure visible columns in track-list
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -54,8 +54,9 @@ const (
|
||||
|
||||
// Config events.
|
||||
const (
|
||||
LibraryConfigChanged = "LibraryConfigChanged"
|
||||
ThemeConfigChanged = "ThemeConfigChanged"
|
||||
LibraryConfigChanged = "LibraryConfigChanged"
|
||||
ThemeConfigChanged = "ThemeConfigChanged"
|
||||
TrackListConfigChanged = "TrackListConfigChanged"
|
||||
)
|
||||
|
||||
// Playlist events.
|
||||
|
||||
+33
-13
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user