feat: allow multi valued tag modes to be configurable
This commit is contained in:
59
README.md
59
README.md
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<b>irc</b> <a href="https://web.libera.chat/#gonic">#gonic</a> on libera.chat
|
<b>irc</b> <a href="https://web.libera.chat/#gonic">#gonic</a> on libera.chat
|
||||||
|
|
|
|
||||||
<b>matrix</b> <a href="https://matrix.to/#/#gonic:libera.chat">#gonic:libera.chat</a>
|
<b>matrix</b> <a href="https://matrix.to/#/#gonic:libera.chat">#gonic:libera.chat</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
- [last.fm](https://www.last.fm/) scrobbling
|
- [last.fm](https://www.last.fm/) scrobbling
|
||||||
- [listenbrainz](https://listenbrainz.org/) scrobbling (thank you [spezifisch](https://github.com/spezifisch), [lxea](https://github.com/lxea))
|
- [listenbrainz](https://listenbrainz.org/) scrobbling (thank you [spezifisch](https://github.com/spezifisch), [lxea](https://github.com/lxea))
|
||||||
- artist similarities and biographies from the last.fm api
|
- artist similarities and biographies from the last.fm api
|
||||||
- multiple genre support (see `GONIC_GENRE_SPLIT` to split tag strings on a character, eg. `;`, and browse them individually)
|
- support for multi valued tags like albumartists and genres ([see more](#multi-valued-tags)
|
||||||
- a web interface for configuration (set up last.fm, manage users, start scans, etc.)
|
- a web interface for configuration (set up last.fm, manage users, start scans, etc.)
|
||||||
- support for the [album-artist](https://mkoby.com/2007/02/18/artist-versus-album-artist/) tag, to not clutter your artist list with compilation album appearances
|
- support for the [album-artist](https://mkoby.com/2007/02/18/artist-versus-album-artist/) tag, to not clutter your artist list with compilation album appearances
|
||||||
- written in [go](https://golang.org/), so lightweight and suitable for a raspberry pi, etc. (see ARM images below)
|
- written in [go](https://golang.org/), so lightweight and suitable for a raspberry pi, etc. (see ARM images below)
|
||||||
@@ -57,26 +57,41 @@ password can then be changed from the web interface
|
|||||||
|
|
||||||
## configuration options
|
## configuration options
|
||||||
|
|
||||||
| env var | command line arg | description |
|
| env var | command line arg | description |
|
||||||
| ------------------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `GONIC_MUSIC_PATH` | `-music-path` | path to your music collection (see also multi-folder support below) |
|
| `GONIC_MUSIC_PATH` | `-music-path` | path to your music collection (see also multi-folder support below) |
|
||||||
| `GONIC_PODCAST_PATH` | `-podcast-path` | path to a podcasts directory |
|
| `GONIC_PODCAST_PATH` | `-podcast-path` | path to a podcasts directory |
|
||||||
| `GONIC_PLAYLISTS_PATH` | `-playlists-path` | path to new or existing directory with m3u files for subsonic playlists. items in the directory should be in the format `<userid>/<name>.m3u`. for example the admin user could have `1/my-playlist.m3u`. gonic create and make changes to these playlists over the subsonic api. |
|
| `GONIC_PLAYLISTS_PATH` | `-playlists-path` | path to new or existing directory with m3u files for subsonic playlists. items in the directory should be in the format `<userid>/<name>.m3u`. for example the admin user could have `1/my-playlist.m3u`. gonic create and make changes to these playlists over the subsonic api. |
|
||||||
| `GONIC_CACHE_PATH` | `-cache-path` | path to store audio transcodes, covers, etc |
|
| `GONIC_CACHE_PATH` | `-cache-path` | path to store audio transcodes, covers, etc |
|
||||||
| `GONIC_DB_PATH` | `-db-path` | **optional** path to database file |
|
| `GONIC_DB_PATH` | `-db-path` | **optional** path to database file |
|
||||||
| `GONIC_HTTP_LOG` | `-http-log` | **optional** http request logging, enabled by default |
|
| `GONIC_HTTP_LOG` | `-http-log` | **optional** http request logging, enabled by default |
|
||||||
| `GONIC_LISTEN_ADDR` | `-listen-addr` | **optional** host and port to listen on (eg. `0.0.0.0:4747`, `127.0.0.1:4747`) (_default_ `0.0.0.0:4747`) |
|
| `GONIC_LISTEN_ADDR` | `-listen-addr` | **optional** host and port to listen on (eg. `0.0.0.0:4747`, `127.0.0.1:4747`) (_default_ `0.0.0.0:4747`) |
|
||||||
| `GONIC_TLS_CERT` | `-tls-cert` | **optional** path to a TLS cert (enables HTTPS listening) |
|
| `GONIC_TLS_CERT` | `-tls-cert` | **optional** path to a TLS cert (enables HTTPS listening) |
|
||||||
| `GONIC_TLS_KEY` | `-tls-key` | **optional** path to a TLS key (enables HTTPS listening) |
|
| `GONIC_TLS_KEY` | `-tls-key` | **optional** path to a TLS key (enables HTTPS listening) |
|
||||||
| `GONIC_PROXY_PREFIX` | `-proxy-prefix` | **optional** url path prefix to use if behind reverse proxy. eg `/gonic` (see example configs below) |
|
| `GONIC_PROXY_PREFIX` | `-proxy-prefix` | **optional** url path prefix to use if behind reverse proxy. eg `/gonic` (see example configs below) |
|
||||||
| `GONIC_SCAN_INTERVAL` | `-scan-interval` | **optional** interval (in minutes) to check for new music (automatic scanning disabled if omitted) |
|
| `GONIC_SCAN_INTERVAL` | `-scan-interval` | **optional** interval (in minutes) to check for new music (automatic scanning disabled if omitted) |
|
||||||
| `GONIC_SCAN_AT_START_ENABLED` | `-scan-at-start-enabled` | **optional** whether to perform an initial scan at startup |
|
| `GONIC_SCAN_AT_START_ENABLED` | `-scan-at-start-enabled` | **optional** whether to perform an initial scan at startup |
|
||||||
| `GONIC_SCAN_WATCHER_ENABLED` | `-scan-watcher-enabled` | **optional** whether to watch file system for new music and rescan |
|
| `GONIC_SCAN_WATCHER_ENABLED` | `-scan-watcher-enabled` | **optional** whether to watch file system for new music and rescan |
|
||||||
| `GONIC_JUKEBOX_ENABLED` | `-jukebox-enabled` | **optional** whether the subsonic [jukebox api](https://airsonic.github.io/docs/jukebox/) should be enabled |
|
| `GONIC_JUKEBOX_ENABLED` | `-jukebox-enabled` | **optional** whether the subsonic [jukebox api](https://airsonic.github.io/docs/jukebox/) should be enabled |
|
||||||
| `GONIC_JUKEBOX_MPV_EXTRA_ARGS` | `-jukebox-mpv-extra-args` | **optional** extra command line arguments to pass to the jukebox mpv daemon |
|
| `GONIC_JUKEBOX_MPV_EXTRA_ARGS` | `-jukebox-mpv-extra-args` | **optional** extra command line arguments to pass to the jukebox mpv daemon |
|
||||||
| `GONIC_PODCAST_PURGE_AGE` | `-podcast-purge-age` | **optional** age (in days) to purge podcast episodes if not accessed |
|
| `GONIC_PODCAST_PURGE_AGE` | `-podcast-purge-age` | **optional** age (in days) to purge podcast episodes if not accessed |
|
||||||
| `GONIC_GENRE_SPLIT` | `-genre-split` | **optional** a string or character to split genre tags on for multi-genre support (eg. `;`) |
|
| `GONIC_EXCLUDE_PATTERN` | `-exclude-pattern` | **optional** files matching this regex pattern will not be imported |
|
||||||
| `GONIC_EXCLUDE_PATTERN` | `-exclude-pattern` | **optional** files matching this regex pattern will not be imported |
|
| `GONIC_MULTI_VALUE_GENRE` | `-multi-value-genre` | **optional** setting for multi-valued genre tags when scanning ([see more](#multi-valued-tags)) |
|
||||||
|
| `GONIC_MULTI_VALUE_ALBUM_ARTIST` | `-multi-value-album-artist` | **optional** setting for multi-valued album artist tags when scanning ([see more](#multi-valued-tags)) |
|
||||||
|
|
||||||
|
## multi valued tags
|
||||||
|
|
||||||
|
gonic can support potentially multi valued tags like `genres` and `albumartists`. in both cases gonic will individual entries in its database for each.
|
||||||
|
|
||||||
|
this means being able to click find album "X" under both "techno" and "house" for example. or finding the album "My Life in the Bush of Ghosts" under either "David Byrne" or "Brian Eno". it also means not cluttering up your artists list with "A & X", "A and Y", "A ft. Z", etc. you will only have A, X, Y, and Z.
|
||||||
|
|
||||||
|
the available modes are:
|
||||||
|
|
||||||
|
| value | desc |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `multi` | gonic will explictly look for multi value fields in your audio metadata. software like musicbrainz picard or beets can set set these ([soon](https://github.com/beetbox/beets/pull/4743)) |
|
||||||
|
| `delim <delim>` | gonic will look at your normal audio metadata fields like "genre" or "album_artist", but split them on a delimiter. for example you could set `-multi-value-genre "delim ;"` to split the single genre field on ";" |
|
||||||
|
| `none` (default) | gonic will not attempt to do any multi value processing |
|
||||||
|
|
||||||
## screenshots
|
## screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"go.senan.xyz/gonic"
|
"go.senan.xyz/gonic"
|
||||||
"go.senan.xyz/gonic/db"
|
"go.senan.xyz/gonic/db"
|
||||||
|
"go.senan.xyz/gonic/scanner"
|
||||||
"go.senan.xyz/gonic/server"
|
"go.senan.xyz/gonic/server"
|
||||||
"go.senan.xyz/gonic/server/ctrlsubsonic"
|
"go.senan.xyz/gonic/server/ctrlsubsonic"
|
||||||
)
|
)
|
||||||
@@ -59,13 +60,17 @@ func main() {
|
|||||||
confProxyPrefix := set.String("proxy-prefix", "", "url path prefix to use if behind proxy. eg '/gonic' (optional)")
|
confProxyPrefix := set.String("proxy-prefix", "", "url path prefix to use if behind proxy. eg '/gonic' (optional)")
|
||||||
confHTTPLog := set.Bool("http-log", true, "http request logging (optional)")
|
confHTTPLog := set.Bool("http-log", true, "http request logging (optional)")
|
||||||
|
|
||||||
confGenreSplit := set.String("genre-split", "\n", "character or string to split genre tag data on (optional)")
|
|
||||||
|
|
||||||
confShowVersion := set.Bool("version", false, "show gonic version")
|
confShowVersion := set.Bool("version", false, "show gonic version")
|
||||||
_ = set.String("config-path", "", "path to config (optional)")
|
_ = set.String("config-path", "", "path to config (optional)")
|
||||||
|
|
||||||
confExcludePatterns := set.String("exclude-pattern", "", "regex pattern to exclude files from scan (optional)")
|
confExcludePatterns := set.String("exclude-pattern", "", "regex pattern to exclude files from scan (optional)")
|
||||||
|
|
||||||
|
var confMultiValueGenre, confMultiValueAlbumArtist multiValueSetting
|
||||||
|
set.Var(&confMultiValueGenre, "multi-value-genre", "setting for mutli-valued genre scanning (optional)")
|
||||||
|
set.Var(&confMultiValueAlbumArtist, "multi-value-album-artist", "setting for mutli-valued album artist scanning (optional)")
|
||||||
|
|
||||||
|
deprecatedConfGenreSplit := set.String("genre-split", "", "(deprecated, see multi-value settings)")
|
||||||
|
|
||||||
if _, err := regexp.Compile(*confExcludePatterns); err != nil {
|
if _, err := regexp.Compile(*confExcludePatterns); err != nil {
|
||||||
log.Fatalf("invalid exclude pattern: %v\n", err)
|
log.Fatalf("invalid exclude pattern: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -135,6 +140,12 @@ func main() {
|
|||||||
|
|
||||||
proxyPrefixExpr := regexp.MustCompile(`^\/*(.*?)\/*$`)
|
proxyPrefixExpr := regexp.MustCompile(`^\/*(.*?)\/*$`)
|
||||||
*confProxyPrefix = proxyPrefixExpr.ReplaceAllString(*confProxyPrefix, `/$1`)
|
*confProxyPrefix = proxyPrefixExpr.ReplaceAllString(*confProxyPrefix, `/$1`)
|
||||||
|
|
||||||
|
if *deprecatedConfGenreSplit != "" && *deprecatedConfGenreSplit != "\n" {
|
||||||
|
confMultiValueGenre = multiValueSetting{Mode: scanner.Delim, Delim: *deprecatedConfGenreSplit}
|
||||||
|
*deprecatedConfGenreSplit = "<deprecated>"
|
||||||
|
}
|
||||||
|
|
||||||
server, err := server.New(server.Options{
|
server, err := server.New(server.Options{
|
||||||
DB: dbc,
|
DB: dbc,
|
||||||
MusicPaths: musicPaths,
|
MusicPaths: musicPaths,
|
||||||
@@ -144,7 +155,10 @@ func main() {
|
|||||||
PodcastPath: *confPodcastPath,
|
PodcastPath: *confPodcastPath,
|
||||||
PlaylistsPath: *confPlaylistsPath,
|
PlaylistsPath: *confPlaylistsPath,
|
||||||
ProxyPrefix: *confProxyPrefix,
|
ProxyPrefix: *confProxyPrefix,
|
||||||
GenreSplit: *confGenreSplit,
|
MultiValueSettings: map[scanner.Tag]scanner.MultiValueSetting{
|
||||||
|
scanner.Genre: scanner.MultiValueSetting(confMultiValueGenre),
|
||||||
|
scanner.AlbumArtist: scanner.MultiValueSetting(confMultiValueAlbumArtist),
|
||||||
|
},
|
||||||
HTTPLog: *confHTTPLog,
|
HTTPLog: *confHTTPLog,
|
||||||
JukeboxEnabled: *confJukeboxEnabled,
|
JukeboxEnabled: *confJukeboxEnabled,
|
||||||
})
|
})
|
||||||
@@ -212,14 +226,12 @@ func (pa *pathAliases) Set(value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var errNotExists = errors.New("path does not exist, please provide one")
|
|
||||||
|
|
||||||
func validatePath(p string) (string, error) {
|
func validatePath(p string) (string, error) {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return "", errNotExists
|
return "", errors.New("path can't be empty")
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||||
return "", errNotExists
|
return "", errors.New("path does not exist, please provide one")
|
||||||
}
|
}
|
||||||
p, err := filepath.Abs(p)
|
p, err := filepath.Abs(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -227,3 +239,34 @@ func validatePath(p string) (string, error) {
|
|||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type multiValueSetting scanner.MultiValueSetting
|
||||||
|
|
||||||
|
func (mvs multiValueSetting) String() string {
|
||||||
|
switch mvs.Mode {
|
||||||
|
case scanner.Delim:
|
||||||
|
return fmt.Sprintf("delim(%s)", mvs.Delim)
|
||||||
|
case scanner.Multi:
|
||||||
|
return fmt.Sprint("multi", mvs.Delim)
|
||||||
|
default:
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mvs *multiValueSetting) Set(value string) error {
|
||||||
|
mode, delim, _ := strings.Cut(value, " ")
|
||||||
|
switch mode {
|
||||||
|
case "delim":
|
||||||
|
if delim == "" {
|
||||||
|
return fmt.Errorf("no delimiter provided for delimiter mode")
|
||||||
|
}
|
||||||
|
mvs.Mode = scanner.Delim
|
||||||
|
mvs.Delim = delim
|
||||||
|
case "multi":
|
||||||
|
mvs.Mode = scanner.Multi
|
||||||
|
case "none":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf(`unknown multi value mode %q. should be "none" | "multi" | "delim <delim>"`, mode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,3 @@ cache-path /var/cache/gonic
|
|||||||
#scan-watcher-enabled false
|
#scan-watcher-enabled false
|
||||||
#jukebox-enabled false
|
#jukebox-enabled false
|
||||||
#jukebox-mpv-extra-args <extra command line arguments to pass to the jukebox mpv daemon>
|
#jukebox-mpv-extra-args <extra command line arguments to pass to the jukebox mpv daemon>
|
||||||
|
|
||||||
# A string or character to split genre tags on for multi-genre support (e.g. ;)
|
|
||||||
#genre-split ;
|
|
||||||
|
|||||||
@@ -61,8 +61,13 @@ func newMockFS(t testing.TB, dirs []string, excludePattern string) *MockFS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
multiValueSettings := map[scanner.Tag]scanner.MultiValueSetting{
|
||||||
|
scanner.Genre: {Mode: scanner.Delim, Delim: ";"},
|
||||||
|
scanner.AlbumArtist: {Mode: scanner.Multi},
|
||||||
|
}
|
||||||
|
|
||||||
tagReader := &tagReader{paths: map[string]*tagReaderResult{}}
|
tagReader := &tagReader{paths: map[string]*tagReaderResult{}}
|
||||||
scanner := scanner.New(absDirs, dbc, ";", tagReader, excludePattern)
|
scanner := scanner.New(absDirs, dbc, multiValueSettings, tagReader, excludePattern)
|
||||||
|
|
||||||
return &MockFS{
|
return &MockFS{
|
||||||
t: t,
|
t: t,
|
||||||
@@ -383,6 +388,7 @@ func (m *Tags) AlbumArtist() string { return m.RawAlbumArtist }
|
|||||||
func (m *Tags) AlbumArtists() []string { return m.RawAlbumArtists }
|
func (m *Tags) AlbumArtists() []string { return m.RawAlbumArtists }
|
||||||
func (m *Tags) AlbumBrainzID() string { return "" }
|
func (m *Tags) AlbumBrainzID() string { return "" }
|
||||||
func (m *Tags) Genre() string { return m.RawGenre }
|
func (m *Tags) Genre() string { return m.RawGenre }
|
||||||
|
func (m *Tags) Genres() []string { return []string{m.RawGenre} }
|
||||||
func (m *Tags) TrackNumber() int { return 1 }
|
func (m *Tags) TrackNumber() int { return 1 }
|
||||||
func (m *Tags) DiscNumber() int { return 1 }
|
func (m *Tags) DiscNumber() int { return 1 }
|
||||||
func (m *Tags) Year() int { return 2021 }
|
func (m *Tags) Year() int { return 2021 }
|
||||||
|
|||||||
@@ -30,32 +30,32 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
musicDirs []string
|
musicDirs []string
|
||||||
genreSplit string
|
multiValueSettings map[Tag]MultiValueSetting
|
||||||
tagger tags.Reader
|
tagger tags.Reader
|
||||||
excludePattern *regexp.Regexp
|
excludePattern *regexp.Regexp
|
||||||
scanning *int32
|
scanning *int32
|
||||||
watcher *fsnotify.Watcher
|
watcher *fsnotify.Watcher
|
||||||
watchMap map[string]string // maps watched dirs back to root music dir
|
watchMap map[string]string // maps watched dirs back to root music dir
|
||||||
watchDone chan bool
|
watchDone chan bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(musicDirs []string, db *db.DB, genreSplit string, tagger tags.Reader, excludePattern string) *Scanner {
|
func New(musicDirs []string, db *db.DB, multiValueSettings map[Tag]MultiValueSetting, tagger tags.Reader, excludePattern string) *Scanner {
|
||||||
var excludePatternRegExp *regexp.Regexp
|
var excludePatternRegExp *regexp.Regexp
|
||||||
if excludePattern != "" {
|
if excludePattern != "" {
|
||||||
excludePatternRegExp = regexp.MustCompile(excludePattern)
|
excludePatternRegExp = regexp.MustCompile(excludePattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Scanner{
|
return &Scanner{
|
||||||
db: db,
|
db: db,
|
||||||
musicDirs: musicDirs,
|
musicDirs: musicDirs,
|
||||||
genreSplit: genreSplit,
|
multiValueSettings: multiValueSettings,
|
||||||
tagger: tagger,
|
tagger: tagger,
|
||||||
excludePattern: excludePatternRegExp,
|
excludePattern: excludePatternRegExp,
|
||||||
scanning: new(int32),
|
scanning: new(int32),
|
||||||
watchMap: make(map[string]string),
|
watchMap: make(map[string]string),
|
||||||
watchDone: make(chan bool),
|
watchDone: make(chan bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, alb
|
|||||||
return fmt.Errorf("%v: %w", err, ErrReadingTags)
|
return fmt.Errorf("%v: %w", err, ErrReadingTags)
|
||||||
}
|
}
|
||||||
|
|
||||||
genreNames := strings.Split(tags.MustGenre(trags), s.genreSplit)
|
genreNames := parseMulti(trags, s.multiValueSettings[Genre], tags.MustGenres, tags.MustGenre)
|
||||||
genreIDs, err := populateGenres(tx, genreNames)
|
genreIDs, err := populateGenres(tx, genreNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("populate genres: %w", err)
|
return fmt.Errorf("populate genres: %w", err)
|
||||||
@@ -361,9 +361,9 @@ func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *Context, i int, alb
|
|||||||
|
|
||||||
// metadata for the album table comes only from the the first track's tags
|
// metadata for the album table comes only from the the first track's tags
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
albumArtists := tags.MustAlbumArtists(trags)
|
albumArtistNames := parseMulti(trags, s.multiValueSettings[AlbumArtist], tags.MustAlbumArtists, tags.MustAlbumArtist)
|
||||||
var albumArtistIDs []int
|
var albumArtistIDs []int
|
||||||
for _, albumArtistName := range albumArtists {
|
for _, albumArtistName := range albumArtistNames {
|
||||||
albumArtist, err := populateArtist(tx, albumArtistName)
|
albumArtist, err := populateArtist(tx, albumArtistName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("populate album artist: %w", err)
|
return fmt.Errorf("populate album artist: %w", err)
|
||||||
@@ -669,3 +669,39 @@ func (c *Context) TracksMissing() int { return len(c.tracksMissing) }
|
|||||||
func (c *Context) AlbumsMissing() int { return len(c.albumsMissing) }
|
func (c *Context) AlbumsMissing() int { return len(c.albumsMissing) }
|
||||||
func (c *Context) ArtistsMissing() int { return c.artistsMissing }
|
func (c *Context) ArtistsMissing() int { return c.artistsMissing }
|
||||||
func (c *Context) GenresMissing() int { return c.genresMissing }
|
func (c *Context) GenresMissing() int { return c.genresMissing }
|
||||||
|
|
||||||
|
type MultiValueMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
None MultiValueMode = iota
|
||||||
|
Delim
|
||||||
|
Multi
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tag uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Genre Tag = iota
|
||||||
|
AlbumArtist
|
||||||
|
)
|
||||||
|
|
||||||
|
type MultiValueSetting struct {
|
||||||
|
Mode MultiValueMode
|
||||||
|
Delim string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMulti(parser tags.Parser, setting MultiValueSetting, getMulti func(tags.Parser) []string, get func(tags.Parser) string) []string {
|
||||||
|
var parts []string
|
||||||
|
switch setting.Mode {
|
||||||
|
case Multi:
|
||||||
|
parts = getMulti(parser)
|
||||||
|
case Delim:
|
||||||
|
parts = strings.Split(get(parser), setting.Delim)
|
||||||
|
default:
|
||||||
|
parts = []string{get(parser)}
|
||||||
|
}
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = strings.TrimSpace(parts[i])
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func (t *Tagger) AlbumArtist() string { return first(find(t.raw, "albumartist
|
|||||||
func (t *Tagger) AlbumArtists() []string { return find(t.raw, "albumartists", "album_artists") }
|
func (t *Tagger) AlbumArtists() []string { return find(t.raw, "albumartists", "album_artists") }
|
||||||
func (t *Tagger) AlbumBrainzID() string { return first(find(t.raw, "musicbrainz_albumid")) } // musicbrainz release ID
|
func (t *Tagger) AlbumBrainzID() string { return first(find(t.raw, "musicbrainz_albumid")) } // musicbrainz release ID
|
||||||
func (t *Tagger) Genre() string { return first(find(t.raw, "genre")) }
|
func (t *Tagger) Genre() string { return first(find(t.raw, "genre")) }
|
||||||
|
func (t *Tagger) Genres() []string { return find(t.raw, "genres") }
|
||||||
|
|
||||||
func (t *Tagger) TrackNumber() int {
|
func (t *Tagger) TrackNumber() int {
|
||||||
return intSep("/" /* eg. 5/12 */, first(find(t.raw, "tracknumber")))
|
return intSep("/" /* eg. 5/12 */, first(find(t.raw, "tracknumber")))
|
||||||
@@ -56,6 +57,7 @@ type Parser interface {
|
|||||||
AlbumArtists() []string
|
AlbumArtists() []string
|
||||||
AlbumBrainzID() string
|
AlbumBrainzID() string
|
||||||
Genre() string
|
Genre() string
|
||||||
|
Genres() []string
|
||||||
TrackNumber() int
|
TrackNumber() int
|
||||||
DiscNumber() int
|
DiscNumber() int
|
||||||
Length() int
|
Length() int
|
||||||
@@ -127,17 +129,18 @@ func MustArtist(p Parser) string {
|
|||||||
return "Unknown Artist"
|
return "Unknown Artist"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MustAlbumArtist(p Parser) string {
|
||||||
|
if r := p.AlbumArtist(); r != "" {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return MustArtist(p)
|
||||||
|
}
|
||||||
|
|
||||||
func MustAlbumArtists(p Parser) []string {
|
func MustAlbumArtists(p Parser) []string {
|
||||||
if r := p.AlbumArtists(); len(r) > 0 {
|
if r := p.AlbumArtists(); len(r) > 0 {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
if r := p.AlbumArtist(); r != "" {
|
return []string{MustAlbumArtist(p)}
|
||||||
return []string{r}
|
|
||||||
}
|
|
||||||
if r := p.Artist(); r != "" {
|
|
||||||
return []string{r}
|
|
||||||
}
|
|
||||||
return []string{"Unknown Artist"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustGenre(p Parser) string {
|
func MustGenre(p Parser) string {
|
||||||
@@ -146,3 +149,10 @@ func MustGenre(p Parser) string {
|
|||||||
}
|
}
|
||||||
return "Unknown Genre"
|
return "Unknown Genre"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MustGenres(p Parser) []string {
|
||||||
|
if r := p.Genres(); len(r) > 0 {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return []string{MustGenre(p)}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,17 +30,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
DB *db.DB
|
DB *db.DB
|
||||||
MusicPaths []ctrlsubsonic.MusicPath
|
MusicPaths []ctrlsubsonic.MusicPath
|
||||||
ExcludePattern string
|
ExcludePattern string
|
||||||
PodcastPath string
|
PodcastPath string
|
||||||
CacheAudioPath string
|
CacheAudioPath string
|
||||||
CoverCachePath string
|
CoverCachePath string
|
||||||
PlaylistsPath string
|
PlaylistsPath string
|
||||||
ProxyPrefix string
|
ProxyPrefix string
|
||||||
GenreSplit string
|
MultiValueSettings map[scanner.Tag]scanner.MultiValueSetting
|
||||||
HTTPLog bool
|
HTTPLog bool
|
||||||
JukeboxEnabled bool
|
JukeboxEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -54,7 +54,7 @@ type Server struct {
|
|||||||
func New(opts Options) (*Server, error) {
|
func New(opts Options) (*Server, error) {
|
||||||
tagger := &tags.TagReader{}
|
tagger := &tags.TagReader{}
|
||||||
|
|
||||||
scanner := scanner.New(ctrlsubsonic.PathsOf(opts.MusicPaths), opts.DB, opts.GenreSplit, tagger, opts.ExcludePattern)
|
scanner := scanner.New(ctrlsubsonic.PathsOf(opts.MusicPaths), opts.DB, opts.MultiValueSettings, tagger, opts.ExcludePattern)
|
||||||
|
|
||||||
playlistStore, err := playlist.NewStore(opts.PlaylistsPath)
|
playlistStore, err := playlist.NewStore(opts.PlaylistsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user