Skip to content

Commit be58944

Browse files
committed
feat: add genre-tree option to expand genre hierarchies
1 parent 0f98064 commit be58944

File tree

8 files changed

+243
-23
lines changed

8 files changed

+243
-23
lines changed

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)")
@@ -140,6 +142,14 @@ func main() {
140142
log.Fatalf("couldn't create covers cache path: %v\n", err)
141143
}
142144

145+
var genreTree map[string][]string
146+
if *confGenreTree != "" {
147+
genreTree, err = texttree.ParseFile(*confGenreTree)
148+
if err != nil {
149+
log.Fatalf("error parsing genre tree: %v\n", err)
150+
}
151+
}
152+
143153
dbc, err := db.New(*confDBPath, deps.DBDriverOptions(), *confLogDB)
144154
if err != nil {
145155
log.Fatalf("error opening database: %v\n", err)
@@ -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
}
@@ -406,6 +408,30 @@ func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db
406408
return fmt.Errorf("populate genres: %w", err)
407409
}
408410

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

440-
if err := populateAlbumGenres(tx, album, genreIDs); err != nil {
466+
if err := populateAlbumGenres(tx, album, genreIDs, inheritedGenreIDs); err != nil {
441467
return fmt.Errorf("populate album genres: %w", err)
442468
}
443469
}
@@ -454,7 +480,7 @@ func (s *Scanner) populateTrackAndArtists(tx *db.DB, st *State, i int, album *db
454480
if err := populateTrack(tx, s.scanEmbeddedCover, album, track, trprops, trags, basename, int(stat.Size())); err != nil {
455481
return fmt.Errorf("process %q: %w", basename, err)
456482
}
457-
if err := populateTrackGenres(tx, track, genreIDs); err != nil {
483+
if err := populateTrackGenres(tx, track, genreIDs, inheritedGenreIDs); err != nil {
458484
return fmt.Errorf("populate track genres: %w", err)
459485
}
460486

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

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

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

676+
func genreRows(directIDs, inheritedIDs []int) [][]any {
677+
seen := map[int]struct{}{}
678+
var rows [][]any
679+
for _, id := range directIDs {
680+
if _, ok := seen[id]; ok {
681+
continue
682+
}
683+
seen[id] = struct{}{}
684+
rows = append(rows, []any{id, false})
685+
}
686+
for _, id := range inheritedIDs {
687+
if _, ok := seen[id]; ok {
688+
continue
689+
}
690+
seen[id] = struct{}{}
691+
rows = append(rows, []any{id, true})
692+
}
693+
return rows
694+
}
695+
650696
func populateAlbumDiscTitles(tx *db.DB, album *db.Album, discTitles map[int]string) error {
651697
if err := tx.Where("album_id=?", album.ID).Delete(db.AlbumDiscTitle{}).Error; err != nil {
652698
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
Order("genres.name").
456457
Find(&genres)

texttree/texttree.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
stack = append(stack, children[cur]...)
96+
}
97+
return result
98+
}

0 commit comments

Comments
 (0)