Skip to content

Commit 22701ca

Browse files
committed
feat: add genre-tree option to expand genre hierarchies
1 parent 5e916d0 commit 22701ca

8 files changed

Lines changed: 239 additions & 23 deletions

File tree

cmd/gonic/gonic.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import (
4646
"go.senan.xyz/gonic/scrobble"
4747
"go.senan.xyz/gonic/server/ctrladmin"
4848
"go.senan.xyz/gonic/server/ctrlsubsonic"
49+
"go.senan.xyz/gonic/texttree"
4950
"go.senan.xyz/gonic/transcode"
5051
)
5152

@@ -82,6 +83,7 @@ func main() {
8283
confConfigPath := flag.String("config-path", "", "path to config (optional)")
8384

8485
confExcludePattern := flag.String("exclude-pattern", "", "regex pattern to exclude files from scan (optional)")
86+
confGenreTree := flag.String("genre-tree", "", "path to a tab-separated genre tree file for hierarchical genre browsing (optional)")
8587

8688
var confMultiValueGenre, confMultiValueArtist, confMultiValueAlbumArtist multiValueSetting
8789
flag.Var(&confMultiValueGenre, "multi-value-genre", "setting for multi-valued genre scanning (optional)")
@@ -184,6 +186,14 @@ func main() {
184186
log.Printf(" %-30s %s\n", f.Name, value)
185187
})
186188

189+
var genreTree map[string][]string
190+
if *confGenreTree != "" {
191+
genreTree, err = texttree.ParseFile(*confGenreTree)
192+
if err != nil {
193+
log.Fatalf("error parsing genre tree: %v\n", err)
194+
}
195+
}
196+
187197
tagReader := deps.TagReader
188198

189199
scannr := scanner.New(
@@ -197,6 +207,7 @@ func main() {
197207
tagReader,
198208
*confExcludePattern,
199209
*confScanEmbeddedCover,
210+
genreTree,
200211
)
201212
podcast := podcast.New(dbc, *confPodcastPath, tagReader)
202213
transcoder := transcode.NewCachingTranscoder(

db/db.go

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,30 @@ func NewMock(opts url.Values) (*DB, error) {
5454
}
5555

5656
func (db *DB) InsertBulkLeftMany(table string, head []string, left int, col []int) error {
57-
if len(col) == 0 {
57+
var rows [][]any
58+
for _, c := range col {
59+
rows = append(rows, []any{c})
60+
}
61+
return db.InsertBulkLeftManyRows(table, head, left, rows)
62+
}
63+
64+
func (db *DB) InsertBulkLeftManyRows(table string, head []string, left int, rows [][]any) error {
65+
if len(rows) == 0 {
5866
return nil
5967
}
60-
var rows []string
68+
extraCols := len(head) - 1
69+
placeholders := "(?," + strings.TrimSuffix(strings.Repeat(" ?,", extraCols), ",") + ")"
70+
var rowStrs []string
6171
var values []any
62-
for _, c := range col {
63-
rows = append(rows, "(?, ?)")
64-
values = append(values, left, c)
72+
for _, row := range rows {
73+
rowStrs = append(rowStrs, placeholders)
74+
values = append(values, left)
75+
values = append(values, row...)
6576
}
6677
q := fmt.Sprintf("INSERT OR IGNORE INTO %q (%s) VALUES %s",
6778
table,
6879
strings.Join(head, ", "),
69-
strings.Join(rows, ", "),
80+
strings.Join(rowStrs, ", "),
7081
)
7182
return db.Exec(q, values...).Error
7283
}
@@ -410,13 +421,15 @@ type ArtistAppearances struct {
410421
}
411422

412423
type TrackGenre struct {
413-
TrackID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"`
414-
GenreID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"`
424+
TrackID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"`
425+
GenreID int `gorm:"not null; unique_index:idx_track_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"`
426+
Inherited bool `gorm:"not null; default:false"`
415427
}
416428

417429
type AlbumGenre struct {
418-
AlbumID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
419-
GenreID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"`
430+
AlbumID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES albums(id) ON DELETE CASCADE"`
431+
GenreID int `gorm:"not null; unique_index:idx_album_id_genre_id" sql:"default: null; type:int REFERENCES genres(id) ON DELETE CASCADE"`
432+
Inherited bool `gorm:"not null; default:false"`
420433
}
421434

422435
type AlbumDiscTitle struct {

db/migrations.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ func (db *DB) Migrate(ctx MigrationContext) error {
8484
construct(ctx, "202512021147", migrateAlbumAddIndexOnCreatedAt),
8585
construct(ctx, "202601201000", migrateAddAlbumDiscTitles),
8686
construct(ctx, "202602061800", migrateAddTrackYear),
87+
construct(ctx, "202602281000", migrateAddGenreInherited),
8788
}
8889

8990
return gormigrate.
@@ -899,3 +900,8 @@ func migrateAddTrackYear(tx *gorm.DB, _ MigrationContext) error {
899900

900901
return nil
901902
}
903+
904+
func migrateAddGenreInherited(tx *gorm.DB, _ MigrationContext) error {
905+
step := tx.AutoMigrate(TrackGenre{}, AlbumGenre{})
906+
return step.Error
907+
}

mockfs/mockfs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func newMockFS(tb testing.TB, dirs []string, excludePattern string) *MockFS {
6969
}
7070

7171
tagReader := &tagReader{paths: map[string]*TagInfo{}}
72-
scanner := scanner.New(absDirs, dbc, multiValueSettings, tagReader, excludePattern, true)
72+
scanner := scanner.New(absDirs, dbc, multiValueSettings, tagReader, excludePattern, true, nil)
7373

7474
return &MockFS{
7575
t: tb,

scanner/scanner.go

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@ type Scanner struct {
4343
tagReader tags.Reader
4444
excludePattern *regexp.Regexp
4545
scanEmbeddedCover bool
46+
genreTree map[string][]string
4647
scanning *int32
4748
}
4849

49-
func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSetting, tagReader tags.Reader, excludePattern string, scanEmbeddedCover bool) *Scanner {
50+
func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSetting, tagReader tags.Reader, excludePattern string, scanEmbeddedCover bool, genreTree map[string][]string) *Scanner {
5051
var excludePatternRegExp *regexp.Regexp
5152
if excludePattern != "" {
5253
excludePatternRegExp = regexp.MustCompile(excludePattern)
@@ -59,6 +60,7 @@ func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSet
5960
tagReader: tagReader,
6061
excludePattern: excludePatternRegExp,
6162
scanEmbeddedCover: scanEmbeddedCover,
63+
genreTree: genreTree,
6264
scanning: new(int32),
6365
}
6466
}
@@ -405,6 +407,30 @@ func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db
405407
return fmt.Errorf("populate genres: %w", err)
406408
}
407409

410+
var inheritedGenreIDs []int
411+
if len(s.genreTree) > 0 {
412+
direct := map[string]struct{}{}
413+
for _, name := range genreNames {
414+
direct[name] = struct{}{}
415+
}
416+
var inheritedNames []string
417+
for parent, descendants := range s.genreTree {
418+
if _, ok := direct[parent]; ok {
419+
continue
420+
}
421+
for _, desc := range descendants {
422+
if _, ok := direct[desc]; ok {
423+
inheritedNames = append(inheritedNames, parent)
424+
break
425+
}
426+
}
427+
}
428+
inheritedGenreIDs, err = populateGenres(tx, inheritedNames)
429+
if err != nil {
430+
return fmt.Errorf("populate inherited genres: %w", err)
431+
}
432+
}
433+
408434
// metadata for the album table comes only from the first track's tags
409435
if i == 0 {
410436
if err := tx.Where("album_id=?", album.ID).Delete(db.ArtistAppearances{}).Error; err != nil {
@@ -436,7 +462,7 @@ func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db
436462
return fmt.Errorf("populate album: %w", err)
437463
}
438464

439-
if err := populateAlbumGenres(tx, album, genreIDs); err != nil {
465+
if err := populateAlbumGenres(tx, album, genreIDs, inheritedGenreIDs); err != nil {
440466
return fmt.Errorf("populate album genres: %w", err)
441467
}
442468
}
@@ -453,7 +479,7 @@ func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db
453479
if err := populateTrack(tx, s.scanEmbeddedCover, album, track, trprops, trags, basename, int(stat.Size())); err != nil {
454480
return fmt.Errorf("process %q: %w", basename, err)
455481
}
456-
if err := populateTrackGenres(tx, track, genreIDs); err != nil {
482+
if err := populateTrackGenres(tx, track, genreIDs, inheritedGenreIDs); err != nil {
457483
return fmt.Errorf("populate track genres: %w", err)
458484
}
459485

@@ -624,28 +650,48 @@ func populateGenres(tx *db.DB, names []string) ([]int, error) {
624650
return ids, nil
625651
}
626652

627-
func populateTrackGenres(tx *db.DB, track *db.Track, genreIDs []int) error {
653+
func populateTrackGenres(tx *db.DB, track *db.Track, directIDs, inheritedIDs []int) error {
628654
if err := tx.Where("track_id=?", track.ID).Delete(db.TrackGenre{}).Error; err != nil {
629655
return fmt.Errorf("delete old track genre records: %w", err)
630656
}
631-
632-
if err := tx.InsertBulkLeftMany("track_genres", []string{"track_id", "genre_id"}, track.ID, genreIDs); err != nil {
633-
return fmt.Errorf("insert bulk track genres: %w", err)
657+
rows := genreRows(directIDs, inheritedIDs)
658+
if err := tx.InsertBulkLeftManyRows("track_genres", []string{"track_id", "genre_id", "inherited"}, track.ID, rows); err != nil {
659+
return fmt.Errorf("insert track genres: %w", err)
634660
}
635661
return nil
636662
}
637663

638-
func populateAlbumGenres(tx *db.DB, album *db.Album, genreIDs []int) error {
664+
func populateAlbumGenres(tx *db.DB, album *db.Album, directIDs, inheritedIDs []int) error {
639665
if err := tx.Where("album_id=?", album.ID).Delete(db.AlbumGenre{}).Error; err != nil {
640666
return fmt.Errorf("delete old album genre records: %w", err)
641667
}
642-
643-
if err := tx.InsertBulkLeftMany("album_genres", []string{"album_id", "genre_id"}, album.ID, genreIDs); err != nil {
644-
return fmt.Errorf("insert bulk album genres: %w", err)
668+
rows := genreRows(directIDs, inheritedIDs)
669+
if err := tx.InsertBulkLeftManyRows("album_genres", []string{"album_id", "genre_id", "inherited"}, album.ID, rows); err != nil {
670+
return fmt.Errorf("insert album genres: %w", err)
645671
}
646672
return nil
647673
}
648674

675+
func genreRows(directIDs, inheritedIDs []int) [][]any {
676+
seen := map[int]struct{}{}
677+
var rows [][]any
678+
for _, id := range directIDs {
679+
if _, ok := seen[id]; ok {
680+
continue
681+
}
682+
seen[id] = struct{}{}
683+
rows = append(rows, []any{id, false})
684+
}
685+
for _, id := range inheritedIDs {
686+
if _, ok := seen[id]; ok {
687+
continue
688+
}
689+
seen[id] = struct{}{}
690+
rows = append(rows, []any{id, true})
691+
}
692+
return rows
693+
}
694+
649695
func populateAlbumDiscTitles(tx *db.DB, album *db.Album, discTitles map[int]string) error {
650696
if err := tx.Where("album_id=?", album.ID).Delete(db.AlbumDiscTitle{}).Error; err != nil {
651697
return fmt.Errorf("delete old album disc titles: %w", err)

server/ctrlsubsonic/handlers_by_tags.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,8 @@ func (c *Controller) ServeGetGenres(_ *http.Request) *spec.Response {
450450
c.dbc.
451451
Select(`*,
452452
(SELECT count(1) FROM album_genres WHERE genre_id=genres.id) album_count,
453-
(SELECT count(1) FROM track_genres WHERE genre_id=genres.id) track_count`).
453+
(SELECT count(1) FROM track_genres WHERE genre_id=genres.id) track_count
454+
`).
454455
Group("genres.id").
455456
Find(&genres)
456457
sub := spec.NewResponse()

texttree/texttree.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package texttree
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"os"
8+
"strings"
9+
)
10+
11+
func ParseFile(path string) (map[string][]string, error) {
12+
f, err := os.Open(path)
13+
if err != nil {
14+
return nil, fmt.Errorf("open: %w", err)
15+
}
16+
defer f.Close()
17+
return ParseReader(f)
18+
}
19+
20+
func ParseReader(r io.Reader) (map[string][]string, error) {
21+
children := map[string][]string{} // parent name to its direct children
22+
seen := map[string]struct{}{}
23+
parents := map[string]string{} // tracks which parent a child belongs to (for duplicate detection)
24+
25+
scanner := bufio.NewScanner(r)
26+
for scanner.Scan() {
27+
line := strings.TrimSpace(scanner.Text())
28+
if line == "" || strings.HasPrefix(line, "#") {
29+
continue
30+
}
31+
32+
parentName, childName, ok := strings.Cut(line, "\t")
33+
if !ok {
34+
continue
35+
}
36+
37+
parentName = strings.TrimSpace(parentName)
38+
childName = strings.TrimSpace(childName)
39+
if parentName == "" || childName == "" {
40+
continue
41+
}
42+
43+
if _, ok := parents[childName]; ok {
44+
return nil, fmt.Errorf("duplicate child in tree: %q", childName)
45+
}
46+
47+
parents[childName] = parentName
48+
children[parentName] = append(children[parentName], childName)
49+
seen[parentName] = struct{}{}
50+
seen[childName] = struct{}{}
51+
}
52+
if err := scanner.Err(); err != nil {
53+
return nil, fmt.Errorf("reading tree: %w", err)
54+
}
55+
56+
for name := range seen {
57+
if err := checkCycle(name, parents); err != nil {
58+
return nil, err
59+
}
60+
}
61+
62+
tree := map[string][]string{}
63+
for name := range seen {
64+
tree[name] = collectDescendants(name, children)
65+
}
66+
67+
return tree, nil
68+
}
69+
70+
func checkCycle(name string, parents map[string]string) error {
71+
var visited = map[string]bool{}
72+
cur := name
73+
for {
74+
if visited[cur] {
75+
return fmt.Errorf("cycle detected in tree at %q", cur)
76+
}
77+
visited[cur] = true
78+
p, ok := parents[cur]
79+
if !ok {
80+
break
81+
}
82+
cur = p
83+
}
84+
return nil
85+
}
86+
87+
func collectDescendants(name string, children map[string][]string) []string {
88+
var result []string
89+
var stack []string
90+
stack = append(stack, name)
91+
for len(stack) > 0 {
92+
cur := stack[len(stack)-1]
93+
stack = stack[:len(stack)-1]
94+
result = append(result, cur)
95+
for _, child := range children[cur] {
96+
stack = append(stack, child)
97+
}
98+
}
99+
return result
100+
}

texttree/texttree_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package texttree_test
2+
3+
import (
4+
"sort"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"go.senan.xyz/gonic/texttree"
12+
)
13+
14+
func TestParseReader(t *testing.T) {
15+
tree, err := texttree.ParseReader(strings.NewReader("electronic\tedm\nedm\ttechno\n"))
16+
require.NoError(t, err)
17+
18+
check := func(name string, want []string) {
19+
t.Helper()
20+
got := append([]string{}, tree[name]...)
21+
sort.Strings(got)
22+
sort.Strings(want)
23+
assert.Equal(t, want, got, name)
24+
}
25+
26+
check("electronic", []string{"edm", "electronic", "techno"})
27+
check("edm", []string{"edm", "techno"})
28+
check("techno", []string{"techno"})
29+
}
30+
31+
func TestParseReaderCycle(t *testing.T) {
32+
_, err := texttree.ParseReader(strings.NewReader("a\tb\nb\tc\nc\ta\n"))
33+
assert.Error(t, err)
34+
}
35+
36+
func TestParseReaderDuplicateChild(t *testing.T) {
37+
_, err := texttree.ParseReader(strings.NewReader("a\tb\nc\tb\n"))
38+
assert.Error(t, err)
39+
}

0 commit comments

Comments
 (0)