From 9cb1c6674ae87bc84ba8f111aff910adac14d332 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sun, 22 Feb 2026 13:59:28 -0500 Subject: [PATCH] changed genre scanning to parse multiple genres, updated db to accommodate. --- backend/database/sql/queries/audio_files.sql | 8 +- backend/database/sql/queries/genres.sql | 24 ++++ .../sql/schemas/artist_credit_artist.sql | 6 + backend/database/sql/schemas/audio_files.sql | 3 + backend/database/sql/schemas/file_types.sql | 5 + .../database/sql/schemas/genre_recordings.sql | 14 +++ backend/database/sql/schemas/genres.sql | 4 + backend/database/sql/schemas/indexes.sql | 32 ----- .../database/sql/schemas/playlist_tracks.sql | 6 + backend/database/sql/schemas/queue_tracks.sql | 3 + backend/database/sql/schemas/recordings.sql | 3 + .../sql/schemas/release_group_recordings.sql | 6 + .../database/sql/schemas/release_groups.sql | 6 + .../database/sql/sqlcgen/audio_files.sql.go | 8 +- backend/database/sql/sqlcgen/genres.sql.go | 96 +++++++++++++++ backend/database/sql/sqlcgen/models.go | 11 ++ backend/library/library.go | 68 ++++++++++- backend/library/query.go | 19 ++- backend/library/rescan.go | 12 ++ backend/metadata/genre.go | 48 ++++++++ backend/metadata/genre_test.go | 109 ++++++++++++++++++ frontend/src/components/track-list/columns.ts | 2 +- frontend/wailsjs/go/models.ts | 2 +- go.mod | 2 +- 24 files changed, 457 insertions(+), 40 deletions(-) create mode 100644 backend/database/sql/queries/genres.sql create mode 100644 backend/database/sql/schemas/genre_recordings.sql create mode 100644 backend/database/sql/schemas/genres.sql delete mode 100644 backend/database/sql/schemas/indexes.sql create mode 100644 backend/database/sql/sqlcgen/genres.sql.go create mode 100644 backend/metadata/genre.go create mode 100644 backend/metadata/genre_test.go diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index bdff729..885a049 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -80,7 +80,13 @@ SELECT r.track_number, r.disc_number, COALESCE(rg.name, '') AS album, - COALESCE(r.genre, '') AS genre, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, COALESCE(r.year, 0) AS year, COALESCE(r.composer, '') AS composer, COALESCE(ft.extension, '') AS file_type diff --git a/backend/database/sql/queries/genres.sql b/backend/database/sql/queries/genres.sql new file mode 100644 index 0000000..b5c15ff --- /dev/null +++ b/backend/database/sql/queries/genres.sql @@ -0,0 +1,24 @@ +-- name: UpsertGenre :one +INSERT INTO genres (name) VALUES (?) +ON CONFLICT(name) DO UPDATE SET name = name +RETURNING *; + +-- name: CreateRecordingGenre :exec +INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) +VALUES (?, ?); + +-- name: DeleteRecordingGenres :exec +DELETE FROM recording_genres +WHERE recording_id = ?; + +-- name: GetGenresByRecordingID :many +SELECT g.* +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +WHERE rg.recording_id = ?; + +-- name: DeleteAllRecordingGenres :exec +DELETE FROM recording_genres; + +-- name: DeleteAllGenres :exec +DELETE FROM genres; diff --git a/backend/database/sql/schemas/artist_credit_artist.sql b/backend/database/sql/schemas/artist_credit_artist.sql index 11a8cc5..730ad0c 100644 --- a/backend/database/sql/schemas/artist_credit_artist.sql +++ b/backend/database/sql/schemas/artist_credit_artist.sql @@ -5,3 +5,9 @@ CREATE TABLE IF NOT EXISTS artist_credit_artist ( FOREIGN KEY(artist_id) REFERENCES artists(id), FOREIGN KEY(credit_id) REFERENCES artist_credit(id) ); + +CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id + ON artist_credit_artist(artist_id); + +CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id + ON artist_credit_artist(credit_id); diff --git a/backend/database/sql/schemas/audio_files.sql b/backend/database/sql/schemas/audio_files.sql index fc4d7ef..4cbb128 100644 --- a/backend/database/sql/schemas/audio_files.sql +++ b/backend/database/sql/schemas/audio_files.sql @@ -7,3 +7,6 @@ CREATE TABLE IF NOT EXISTS audio_files ( FOREIGN KEY(file_type_id) REFERENCES file_types(id), FOREIGN KEY(recording_id) REFERENCES recordings(id) ); + +CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id + ON audio_files(recording_id); diff --git a/backend/database/sql/schemas/file_types.sql b/backend/database/sql/schemas/file_types.sql index 073d613..d71b7c5 100644 --- a/backend/database/sql/schemas/file_types.sql +++ b/backend/database/sql/schemas/file_types.sql @@ -2,3 +2,8 @@ CREATE TABLE IF NOT EXISTS file_types ( id integer PRIMARY KEY, extension text NOT NULL UNIQUE ); + +INSERT OR IGNORE INTO file_types (id, extension) VALUES (0, '.mp3'); +INSERT OR IGNORE INTO file_types (id, extension) VALUES (1, '.flac'); +INSERT OR IGNORE INTO file_types (id, extension) VALUES (2, '.ogg'); +INSERT OR IGNORE INTO file_types (id, extension) VALUES (3, '.wav'); diff --git a/backend/database/sql/schemas/genre_recordings.sql b/backend/database/sql/schemas/genre_recordings.sql new file mode 100644 index 0000000..64fc0fe --- /dev/null +++ b/backend/database/sql/schemas/genre_recordings.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS recording_genres ( + id INTEGER PRIMARY KEY, + recording_id INTEGER NOT NULL, + genre_id INTEGER NOT NULL, + FOREIGN KEY(recording_id) REFERENCES recordings(id), + FOREIGN KEY(genre_id) REFERENCES genres(id), + UNIQUE(recording_id, genre_id) +); + +CREATE INDEX IF NOT EXISTS idx_recording_genres_recording_id + ON recording_genres(recording_id); + +CREATE INDEX IF NOT EXISTS idx_recording_genres_genre_id + ON recording_genres(genre_id); diff --git a/backend/database/sql/schemas/genres.sql b/backend/database/sql/schemas/genres.sql new file mode 100644 index 0000000..163a0fb --- /dev/null +++ b/backend/database/sql/schemas/genres.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS genres ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); diff --git a/backend/database/sql/schemas/indexes.sql b/backend/database/sql/schemas/indexes.sql deleted file mode 100644 index 8450d89..0000000 --- a/backend/database/sql/schemas/indexes.sql +++ /dev/null @@ -1,32 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id - ON playlist_tracks(playlist_id); - -CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id - ON playlist_tracks(audio_file_id); - -CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id - ON audio_files(recording_id); - -CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id - ON recordings(artist_credit_id); - -CREATE INDEX IF NOT EXISTS idx_release_group_recordings_recording_id - ON release_group_recordings(recording_id); - -CREATE INDEX IF NOT EXISTS idx_release_group_recordings_release_group_id - ON release_group_recordings(release_group_id); - -CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id - ON release_groups(cover_art_id); - -CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id - ON release_groups(album_artist_credit_id); - -CREATE INDEX IF NOT EXISTS idx_queue_tracks_audio_file_id - ON queue_tracks(audio_file_id); - -CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id - ON artist_credit_artist(artist_id); - -CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id - ON artist_credit_artist(credit_id); diff --git a/backend/database/sql/schemas/playlist_tracks.sql b/backend/database/sql/schemas/playlist_tracks.sql index ad431c3..0d0bb9d 100644 --- a/backend/database/sql/schemas/playlist_tracks.sql +++ b/backend/database/sql/schemas/playlist_tracks.sql @@ -6,3 +6,9 @@ CREATE TABLE IF NOT EXISTS playlist_tracks ( FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE ); + +CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id + ON playlist_tracks(playlist_id); + +CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id + ON playlist_tracks(audio_file_id); diff --git a/backend/database/sql/schemas/queue_tracks.sql b/backend/database/sql/schemas/queue_tracks.sql index 9d8f7bd..5f2026f 100644 --- a/backend/database/sql/schemas/queue_tracks.sql +++ b/backend/database/sql/schemas/queue_tracks.sql @@ -4,3 +4,6 @@ CREATE TABLE IF NOT EXISTS queue_tracks ( position INTEGER NOT NULL, FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE ); + +CREATE INDEX IF NOT EXISTS idx_queue_tracks_audio_file_id + ON queue_tracks(audio_file_id); diff --git a/backend/database/sql/schemas/recordings.sql b/backend/database/sql/schemas/recordings.sql index bcdd322..78bf85b 100644 --- a/backend/database/sql/schemas/recordings.sql +++ b/backend/database/sql/schemas/recordings.sql @@ -11,3 +11,6 @@ CREATE TABLE IF NOT EXISTS recordings ( comment TEXT, FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id) ); + +CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id + ON recordings(artist_credit_id); diff --git a/backend/database/sql/schemas/release_group_recordings.sql b/backend/database/sql/schemas/release_group_recordings.sql index 0c7102b..17cdbb6 100644 --- a/backend/database/sql/schemas/release_group_recordings.sql +++ b/backend/database/sql/schemas/release_group_recordings.sql @@ -7,3 +7,9 @@ CREATE TABLE IF NOT EXISTS release_group_recordings ( FOREIGN KEY(release_group_id) REFERENCES release_groups(id), FOREIGN KEY(recording_id) REFERENCES recordings(id) ); + +CREATE INDEX IF NOT EXISTS idx_release_group_recordings_recording_id + ON release_group_recordings(recording_id); + +CREATE INDEX IF NOT EXISTS idx_release_group_recordings_release_group_id + ON release_group_recordings(release_group_id); diff --git a/backend/database/sql/schemas/release_groups.sql b/backend/database/sql/schemas/release_groups.sql index 7fc4b0b..74607f2 100644 --- a/backend/database/sql/schemas/release_groups.sql +++ b/backend/database/sql/schemas/release_groups.sql @@ -9,3 +9,9 @@ CREATE TABLE IF NOT EXISTS release_groups ( FOREIGN KEY(cover_art_id) REFERENCES cover_art(id), FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id) ); + +CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id + ON release_groups(cover_art_id); + +CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id + ON release_groups(album_artist_credit_id); diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index be04202..f758d26 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -199,7 +199,13 @@ SELECT r.track_number, r.disc_number, COALESCE(rg.name, '') AS album, - COALESCE(r.genre, '') AS genre, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, COALESCE(r.year, 0) AS year, COALESCE(r.composer, '') AS composer, COALESCE(ft.extension, '') AS file_type diff --git a/backend/database/sql/sqlcgen/genres.sql.go b/backend/database/sql/sqlcgen/genres.sql.go new file mode 100644 index 0000000..c022128 --- /dev/null +++ b/backend/database/sql/sqlcgen/genres.sql.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: genres.sql + +package sqlcgen + +import ( + "context" +) + +const createRecordingGenre = `-- name: CreateRecordingGenre :exec +INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) +VALUES (?, ?) +` + +type CreateRecordingGenreParams struct { + RecordingID int64 + GenreID int64 +} + +func (q *Queries) CreateRecordingGenre(ctx context.Context, arg CreateRecordingGenreParams) error { + _, err := q.db.ExecContext(ctx, createRecordingGenre, arg.RecordingID, arg.GenreID) + return err +} + +const deleteAllGenres = `-- name: DeleteAllGenres :exec +DELETE FROM genres +` + +func (q *Queries) DeleteAllGenres(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllGenres) + return err +} + +const deleteAllRecordingGenres = `-- name: DeleteAllRecordingGenres :exec +DELETE FROM recording_genres +` + +func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllRecordingGenres) + return err +} + +const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec +DELETE FROM recording_genres +WHERE recording_id = ? +` + +func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) error { + _, err := q.db.ExecContext(ctx, deleteRecordingGenres, recordingID) + return err +} + +const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many +SELECT g.id, g.name +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +WHERE rg.recording_id = ? +` + +func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) ([]Genre, error) { + rows, err := q.db.QueryContext(ctx, getGenresByRecordingID, recordingID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Genre + for rows.Next() { + var i Genre + if err := rows.Scan(&i.ID, &i.Name); 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 upsertGenre = `-- name: UpsertGenre :one +INSERT INTO genres (name) VALUES (?) +ON CONFLICT(name) DO UPDATE SET name = name +RETURNING id, name +` + +func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) { + row := q.db.QueryRowContext(ctx, upsertGenre, name) + var i Genre + err := row.Scan(&i.ID, &i.Name) + return i, err +} diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index c29d407..c9ad29f 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -45,6 +45,11 @@ type FileType struct { Extension string } +type Genre struct { + ID int64 + Name string +} + type PlayerState struct { ID int64 Volume int64 @@ -95,6 +100,12 @@ type Recording struct { Comment sql.NullString } +type RecordingGenre struct { + ID int64 + RecordingID int64 + GenreID int64 +} + type ReleaseGroup struct { ID int64 Name string diff --git a/backend/library/library.go b/backend/library/library.go index 29acd7f..228dd98 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -43,6 +43,7 @@ type entityCache struct { artists map[string]sqlcgen.Artist releaseGroups map[string]sqlcgen.ReleaseGroup coverArt map[string]sqlcgen.CoverArt + genres map[string]sqlcgen.Genre // linkedCredits tracks artist-credit-artist links already created // so we skip the duplicate INSERT. Key is "artistID:creditID". linkedCredits map[string]struct{} @@ -54,6 +55,7 @@ func newEntityCache() *entityCache { artists: make(map[string]sqlcgen.Artist), releaseGroups: make(map[string]sqlcgen.ReleaseGroup), coverArt: make(map[string]sqlcgen.CoverArt), + genres: make(map[string]sqlcgen.Genre), linkedCredits: make(map[string]struct{}), } } @@ -856,7 +858,10 @@ func (l *Library) processMetadata( ) } - // 6. Link recording to release group. + // 6. Link recording to genres. + l.linkRecordingGenres(q, cache, tags.Genre, recording.ID) + + // 7. Link recording to release group. if releaseGroupID.Valid { _, err = q.CreateReleaseGroupRecording( l.ctx, @@ -990,6 +995,67 @@ func (l *Library) cachedLinkArtist( cache.linkedCredits[linkKey] = struct{}{} } +// cachedUpsertGenre returns the genre for the given name, using +// the cache when possible. +func (l *Library) cachedUpsertGenre( + q *sqlcgen.Queries, + cache *entityCache, + name string, +) (sqlcgen.Genre, error) { + if cached, ok := cache.genres[name]; ok { + return cached, nil + } + + genre, err := q.UpsertGenre(l.ctx, name) + if err != nil { + return sqlcgen.Genre{}, err + } + + cache.genres[name] = genre + + return genre, nil +} + +// linkRecordingGenres parses the raw genre string, upserts each +// individual genre, and creates the recording-genre associations. +func (l *Library) linkRecordingGenres( + q *sqlcgen.Queries, + cache *entityCache, + rawGenre string, + recordingID int64, +) { + genres := metadata.ParseGenres(rawGenre) + + for _, name := range genres { + genre, err := l.cachedUpsertGenre(q, cache, name) + if err != nil { + l.logger.Warn( + "could not upsert genre", + "genre", name, + "err", err, + ) + + continue + } + + err = q.CreateRecordingGenre( + l.ctx, + sqlcgen.CreateRecordingGenreParams{ + RecordingID: recordingID, + GenreID: genre.ID, + }, + ) + if err != nil { + l.logger.Warn( + "could not link recording to genre", + "genre", name, + "recordingID", recordingID, + "err", err, + ) + } + } +} + // resolveAlbumArtistCredit returns the album artist credit ID. // When the AlbumArtist tag is absent or matches the track artist, // the track artist credit is reused. diff --git a/backend/library/query.go b/backend/library/query.go index 371a487..38b5d10 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" "strconv" + "strings" ) // Sentinel errors for library queries. @@ -22,12 +23,26 @@ type Track struct { TrackNumber int64 DiscNumber int64 Album string - Genre string + Genre []string Year int64 Composer string FileType string } +// genreDelimiter is the separator used by GROUP_CONCAT in the +// GetAllTracksWithFullMetadata query. +const genreDelimiter = "||" + +// splitGenres splits a GROUP_CONCAT genre string into individual +// genre names. An empty string returns nil. +func splitGenres(concatenated string) []string { + if concatenated == "" { + return nil + } + + return strings.Split(concatenated, genreDelimiter) +} + // Album represents an album for the cover grid display. type Album struct { ID int64 @@ -75,7 +90,7 @@ func (l *Library) GetAllTracks() ([]Track, error) { TrackNumber: row.TrackNumber.Int64, DiscNumber: row.DiscNumber.Int64, Album: row.Album, - Genre: row.Genre, + Genre: splitGenres(row.Genre), Year: row.Year, Composer: row.Composer, FileType: row.FileType, diff --git a/backend/library/rescan.go b/backend/library/rescan.go index 8658494..8cd90ed 100644 --- a/backend/library/rescan.go +++ b/backend/library/rescan.go @@ -102,6 +102,12 @@ func (l *Library) clearLibraryTables() error { ) } + if err := txq.DeleteAllRecordingGenres(l.ctx); err != nil { + return fmt.Errorf( + "could not clear recording genres: %w", err, + ) + } + if err := txq.DeleteAllReleaseGroupRecordings(l.ctx); err != nil { return fmt.Errorf( "could not clear release group recordings: %w", err, @@ -152,6 +158,12 @@ func (l *Library) clearLibraryTables() error { ) } + if err := txq.DeleteAllGenres(l.ctx); err != nil { + return fmt.Errorf( + "could not clear genres: %w", err, + ) + } + if err := tx.Commit(); err != nil { return fmt.Errorf( "could not commit library clear transaction: %w", err, diff --git a/backend/metadata/genre.go b/backend/metadata/genre.go new file mode 100644 index 0000000..b1d86bb --- /dev/null +++ b/backend/metadata/genre.go @@ -0,0 +1,48 @@ +// Package metadata provides audio file metadata extraction utilities. +package metadata + +import ( + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// genreSeparators defines the characters treated as genre delimiters. +const genreSeparators = ",;" + +// ParseGenres splits a raw genre string on commas and semicolons, +// trims whitespace, normalizes each entry to title case, removes +// duplicates, and returns the unique genre names. An empty or +// whitespace-only input returns nil. +func ParseGenres(raw string) []string { + parts := strings.FieldsFunc( + raw, func(r rune) bool { + return strings.ContainsRune(genreSeparators, r) + }, + ) + + caser := cases.Title(language.English) + seen := make(map[string]struct{}, len(parts)) + + var genres []string + + for _, p := range parts { + name := strings.TrimSpace(p) + if name == "" { + continue + } + + name = caser.String(name) + + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + + genres = append(genres, name) + } + + return genres +} diff --git a/backend/metadata/genre_test.go b/backend/metadata/genre_test.go new file mode 100644 index 0000000..dd502b6 --- /dev/null +++ b/backend/metadata/genre_test.go @@ -0,0 +1,109 @@ +package metadata + +import ( + "testing" +) + +func TestParseGenres(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want []string + }{ + { + name: "single genre", + raw: "Rock", + want: []string{"Rock"}, + }, + { + name: "semicolon separated", + raw: "Rock; Electronic", + want: []string{"Rock", "Electronic"}, + }, + { + name: "comma separated", + raw: "Rock, Jazz", + want: []string{"Rock", "Jazz"}, + }, + { + name: "mixed separators", + raw: "Rock; Pop, Jazz", + want: []string{"Rock", "Pop", "Jazz"}, + }, + { + name: "case normalization deduplicates", + raw: "rock,ROCK,Rock", + want: []string{"Rock"}, + }, + { + name: "whitespace and empty segments", + raw: " Pop ; ; Jazz , ", + want: []string{"Pop", "Jazz"}, + }, + { + name: "empty string", + raw: "", + want: nil, + }, + { + name: "only separators", + raw: ";;,,;,", + want: nil, + }, + { + name: "whitespace only", + raw: " ", + want: nil, + }, + { + name: "title case multi-word genre", + raw: "hip hop; drum and bass", + want: []string{"Hip Hop", "Drum And Bass"}, + }, + { + name: "preserves already correct casing", + raw: "Post-Punk", + want: []string{"Post-Punk"}, + }, + { + name: "duplicate after title case", + raw: "electronic; Electronic; ELECTRONIC", + want: []string{"Electronic"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := ParseGenres(tt.raw) + if !slicesEqual(got, tt.want) { + t.Errorf( + "ParseGenres(%q) = %v, want %v", + tt.raw, got, tt.want, + ) + } + }) + } +} + +// slicesEqual reports whether two string slices are equal. +func slicesEqual(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/frontend/src/components/track-list/columns.ts b/frontend/src/components/track-list/columns.ts index 87d1f0e..c3b7af2 100644 --- a/frontend/src/components/track-list/columns.ts +++ b/frontend/src/components/track-list/columns.ts @@ -44,7 +44,7 @@ export const COLUMN_DEFS: Record = { genre: { id: 'genre', label: 'Genre', - accessor: (t) => t.Genre, + accessor: (t) => (t.Genre ?? []).join(', '), defaultWidth: '120px', }, year: { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 85d9d91..d70e46a 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -94,7 +94,7 @@ export namespace library { TrackNumber: number; DiscNumber: number; Album: string; - Genre: string; + Genre: string[]; Year: number; Composer: string; FileType: string; diff --git a/go.mod b/go.mod index 663e894..80b5b23 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/wailsapp/wails/v2 v2.10.2 golang.org/x/image v0.12.0 golang.org/x/sync v0.19.0 + golang.org/x/text v0.34.0 modernc.org/sqlite v1.45.0 ) @@ -349,7 +350,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect golang.org/x/vuln v1.1.4 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect