changed genre scanning to parse multiple genres, updated db to accommodate.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS genres (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
||||
genre: {
|
||||
id: 'genre',
|
||||
label: 'Genre',
|
||||
accessor: (t) => t.Genre,
|
||||
accessor: (t) => (t.Genre ?? []).join(', '),
|
||||
defaultWidth: '120px',
|
||||
},
|
||||
year: {
|
||||
|
||||
@@ -94,7 +94,7 @@ export namespace library {
|
||||
TrackNumber: number;
|
||||
DiscNumber: number;
|
||||
Album: string;
|
||||
Genre: string;
|
||||
Genre: string[];
|
||||
Year: number;
|
||||
Composer: string;
|
||||
FileType: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user