429 lines
13 KiB
Go
429 lines
13 KiB
Go
// Package main is the gonic server entrypoint
|
|
//
|
|
//nolint:lll,gocyclo
|
|
package main
|
|
|
|
import (
|
|
"errors"
|
|
"expvar"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/shlex"
|
|
"github.com/gorilla/mux"
|
|
"github.com/gorilla/securecookie"
|
|
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
|
"github.com/oklog/run"
|
|
"github.com/peterbourgon/ff"
|
|
"github.com/sentriz/gormstore"
|
|
|
|
"go.senan.xyz/gonic"
|
|
"go.senan.xyz/gonic/db"
|
|
"go.senan.xyz/gonic/jukebox"
|
|
"go.senan.xyz/gonic/playlist"
|
|
"go.senan.xyz/gonic/podcasts"
|
|
"go.senan.xyz/gonic/scanner"
|
|
"go.senan.xyz/gonic/scanner/tags"
|
|
"go.senan.xyz/gonic/scrobble"
|
|
"go.senan.xyz/gonic/scrobble/lastfm"
|
|
"go.senan.xyz/gonic/scrobble/listenbrainz"
|
|
"go.senan.xyz/gonic/server/ctrladmin"
|
|
"go.senan.xyz/gonic/server/ctrlbase"
|
|
"go.senan.xyz/gonic/server/ctrlsubsonic"
|
|
"go.senan.xyz/gonic/transcode"
|
|
)
|
|
|
|
func main() {
|
|
set := flag.NewFlagSet(gonic.Name, flag.ExitOnError)
|
|
confListenAddr := set.String("listen-addr", "0.0.0.0:4747", "listen address (optional)")
|
|
|
|
confTLSCert := set.String("tls-cert", "", "path to TLS certificate (optional)")
|
|
confTLSKey := set.String("tls-key", "", "path to TLS private key (optional)")
|
|
|
|
confPodcastPurgeAgeDays := set.Int("podcast-purge-age", 0, "age (in days) to purge podcast episodes if not accessed (optional)")
|
|
confPodcastPath := set.String("podcast-path", "", "path to podcasts")
|
|
|
|
confCachePath := set.String("cache-path", "", "path to cache")
|
|
|
|
var confMusicPaths pathAliases
|
|
set.Var(&confMusicPaths, "music-path", "path to music")
|
|
|
|
confPlaylistsPath := set.String("playlists-path", "", "path to your list of new or existing m3u playlists that gonic can manage")
|
|
|
|
confDBPath := set.String("db-path", "gonic.db", "path to database (optional)")
|
|
|
|
confScanIntervalMins := set.Int("scan-interval", 0, "interval (in minutes) to automatically scan music (optional)")
|
|
confScanAtStart := set.Bool("scan-at-start-enabled", false, "whether to perform an initial scan at startup (optional)")
|
|
confScanWatcher := set.Bool("scan-watcher-enabled", false, "whether to watch file system for new music and rescan (optional)")
|
|
|
|
confJukeboxEnabled := set.Bool("jukebox-enabled", false, "whether the subsonic jukebox api should be enabled (optional)")
|
|
confJukeboxMPVExtraArgs := set.String("jukebox-mpv-extra-args", "", "extra command line arguments to pass to the jukebox mpv daemon (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)")
|
|
|
|
confShowVersion := set.Bool("version", false, "show gonic version")
|
|
_ = set.String("config-path", "", "path to config (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)")
|
|
|
|
confExpvar := set.Bool("expvar", false, "enable the /debug/vars endpoint (optional)")
|
|
|
|
deprecatedConfGenreSplit := set.String("genre-split", "", "(deprecated, see multi-value settings)")
|
|
|
|
if _, err := regexp.Compile(*confExcludePatterns); err != nil {
|
|
log.Fatalf("invalid exclude pattern: %v\n", err)
|
|
}
|
|
|
|
if err := ff.Parse(set, os.Args[1:],
|
|
ff.WithConfigFileFlag("config-path"),
|
|
ff.WithConfigFileParser(ff.PlainParser),
|
|
ff.WithEnvVarPrefix(gonic.NameUpper),
|
|
); err != nil {
|
|
log.Fatalf("error parsing args: %v\n", err)
|
|
}
|
|
|
|
if *confShowVersion {
|
|
fmt.Printf("v%s\n", gonic.Version)
|
|
os.Exit(0)
|
|
}
|
|
|
|
if len(confMusicPaths) == 0 {
|
|
log.Fatalf("please provide a music directory")
|
|
}
|
|
|
|
var err error
|
|
for i, confMusicPath := range confMusicPaths {
|
|
if confMusicPaths[i].path, err = validatePath(confMusicPath.path); err != nil {
|
|
log.Fatalf("checking music dir %q: %v", confMusicPath.path, err)
|
|
}
|
|
}
|
|
|
|
if *confPodcastPath, err = validatePath(*confPodcastPath); err != nil {
|
|
log.Fatalf("checking podcast directory: %v", err)
|
|
}
|
|
if *confCachePath, err = validatePath(*confCachePath); err != nil {
|
|
log.Fatalf("checking cache directory: %v", err)
|
|
}
|
|
if *confPlaylistsPath, err = validatePath(*confPlaylistsPath); err != nil {
|
|
log.Fatalf("checking playlist directory: %v", err)
|
|
}
|
|
|
|
cacheDirAudio := path.Join(*confCachePath, "audio")
|
|
cacheDirCovers := path.Join(*confCachePath, "covers")
|
|
if err := os.MkdirAll(cacheDirAudio, os.ModePerm); err != nil {
|
|
log.Fatalf("couldn't create audio cache path: %v\n", err)
|
|
}
|
|
if err := os.MkdirAll(cacheDirCovers, os.ModePerm); err != nil {
|
|
log.Fatalf("couldn't create covers cache path: %v\n", err)
|
|
}
|
|
|
|
dbc, err := db.New(*confDBPath, db.DefaultOptions())
|
|
if err != nil {
|
|
log.Fatalf("error opening database: %v\n", err)
|
|
}
|
|
defer dbc.Close()
|
|
|
|
err = dbc.Migrate(db.MigrationContext{
|
|
OriginalMusicPath: confMusicPaths[0].path,
|
|
PlaylistsPath: *confPlaylistsPath,
|
|
PodcastsPath: *confPodcastPath,
|
|
})
|
|
if err != nil {
|
|
log.Panicf("error migrating database: %v\n", err)
|
|
}
|
|
|
|
var musicPaths []ctrlsubsonic.MusicPath
|
|
for _, pa := range confMusicPaths {
|
|
musicPaths = append(musicPaths, ctrlsubsonic.MusicPath{Alias: pa.alias, Path: pa.path})
|
|
}
|
|
|
|
proxyPrefixExpr := regexp.MustCompile(`^\/*(.*?)\/*$`)
|
|
*confProxyPrefix = proxyPrefixExpr.ReplaceAllString(*confProxyPrefix, `/$1`)
|
|
|
|
if *deprecatedConfGenreSplit != "" && *deprecatedConfGenreSplit != "\n" {
|
|
confMultiValueGenre = multiValueSetting{Mode: scanner.Delim, Delim: *deprecatedConfGenreSplit}
|
|
*deprecatedConfGenreSplit = "<deprecated>"
|
|
}
|
|
|
|
log.Printf("starting gonic v%s\n", gonic.Version)
|
|
log.Printf("provided config\n")
|
|
set.VisitAll(func(f *flag.Flag) {
|
|
value := strings.ReplaceAll(f.Value.String(), "\n", "")
|
|
log.Printf(" %-25s %s\n", f.Name, value)
|
|
})
|
|
|
|
tagger := &tags.TagReader{}
|
|
scannr := scanner.New(
|
|
ctrlsubsonic.PathsOf(musicPaths),
|
|
dbc,
|
|
map[scanner.Tag]scanner.MultiValueSetting{
|
|
scanner.Genre: scanner.MultiValueSetting(confMultiValueGenre),
|
|
scanner.AlbumArtist: scanner.MultiValueSetting(confMultiValueAlbumArtist),
|
|
},
|
|
tagger,
|
|
*confExcludePatterns,
|
|
)
|
|
podcast := podcasts.New(dbc, *confPodcastPath, tagger)
|
|
transcoder := transcode.NewCachingTranscoder(
|
|
transcode.NewFFmpegTranscoder(),
|
|
cacheDirAudio,
|
|
)
|
|
lastfmClient := lastfm.NewClient()
|
|
playlistStore, err := playlist.NewStore(*confPlaylistsPath)
|
|
if err != nil {
|
|
log.Panicf("error creating playlists store: %v", err)
|
|
}
|
|
|
|
var jukebx *jukebox.Jukebox
|
|
if *confJukeboxEnabled {
|
|
jukebx = jukebox.New()
|
|
}
|
|
|
|
sessKey, err := dbc.GetSetting("session_key")
|
|
if err != nil {
|
|
log.Panicf("error getting session key: %v\n", err)
|
|
}
|
|
if sessKey == "" {
|
|
if err := dbc.SetSetting("session_key", string(securecookie.GenerateRandomKey(32))); err != nil {
|
|
log.Panicf("error setting session key: %v\n", err)
|
|
}
|
|
}
|
|
sessDB := gormstore.New(dbc.DB, []byte(sessKey))
|
|
sessDB.SessionOpts.HttpOnly = true
|
|
sessDB.SessionOpts.SameSite = http.SameSiteLaxMode
|
|
|
|
ctrlBase := &ctrlbase.Controller{
|
|
DB: dbc,
|
|
PlaylistStore: playlistStore,
|
|
ProxyPrefix: *confProxyPrefix,
|
|
Scanner: scannr,
|
|
}
|
|
ctrlAdmin, err := ctrladmin.New(ctrlBase, sessDB, podcast, lastfmClient)
|
|
if err != nil {
|
|
log.Panicf("error creating admin controller: %v\n", err)
|
|
}
|
|
ctrlSubsonic := &ctrlsubsonic.Controller{
|
|
Controller: ctrlBase,
|
|
MusicPaths: musicPaths,
|
|
PodcastsPath: *confPodcastPath,
|
|
CacheAudioPath: cacheDirAudio,
|
|
CacheCoverPath: cacheDirCovers,
|
|
LastFMClient: lastfmClient,
|
|
Scrobblers: []scrobble.Scrobbler{
|
|
lastfm.NewScrobbler(dbc, lastfmClient),
|
|
listenbrainz.NewScrobbler(),
|
|
},
|
|
Podcasts: podcast,
|
|
Transcoder: transcoder,
|
|
Jukebox: jukebx,
|
|
}
|
|
|
|
mux := mux.NewRouter()
|
|
ctrlbase.AddRoutes(ctrlBase, mux, *confHTTPLog)
|
|
ctrladmin.AddRoutes(ctrlAdmin, mux.PathPrefix("/admin").Subrouter())
|
|
ctrlsubsonic.AddRoutes(ctrlSubsonic, mux.PathPrefix("/rest").Subrouter())
|
|
|
|
if *confExpvar {
|
|
mux.Handle("/debug/vars", expvar.Handler())
|
|
expvar.Publish("stats", expvar.Func(func() any {
|
|
var stats struct{ Albums, Tracks, Artists, InternetRadioStations, Podcasts uint }
|
|
dbc.Model(db.Album{}).Count(&stats.Albums)
|
|
dbc.Model(db.Track{}).Count(&stats.Tracks)
|
|
dbc.Model(db.Artist{}).Count(&stats.Artists)
|
|
dbc.Model(db.InternetRadioStation{}).Count(&stats.InternetRadioStations)
|
|
dbc.Model(db.Podcast{}).Count(&stats.Podcasts)
|
|
return stats
|
|
}))
|
|
}
|
|
|
|
var g run.Group
|
|
g.Add(func() error {
|
|
log.Print("starting job 'http'\n")
|
|
server := &http.Server{
|
|
Addr: *confListenAddr,
|
|
Handler: mux,
|
|
ReadTimeout: 5 * time.Second,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
WriteTimeout: 80 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
if *confTLSCert != "" && *confTLSKey != "" {
|
|
return server.ListenAndServeTLS(*confTLSCert, *confTLSKey)
|
|
}
|
|
return server.ListenAndServe()
|
|
}, nil)
|
|
|
|
g.Add(func() error {
|
|
log.Printf("starting job 'session clean'\n")
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
for range ticker.C {
|
|
sessDB.Cleanup()
|
|
}
|
|
return nil
|
|
}, nil)
|
|
|
|
g.Add(func() error {
|
|
log.Printf("starting job 'podcast refresher'\n")
|
|
ticker := time.NewTicker(time.Hour)
|
|
for range ticker.C {
|
|
if err := podcast.RefreshPodcasts(); err != nil {
|
|
log.Printf("failed to refresh some feeds: %s", err)
|
|
}
|
|
}
|
|
return nil
|
|
}, nil)
|
|
|
|
g.Add(func() error {
|
|
log.Printf("starting job 'podcast purger'\n")
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
for range ticker.C {
|
|
if err := podcast.PurgeOldPodcasts(time.Duration(*confPodcastPurgeAgeDays) * 24 * time.Hour); err != nil {
|
|
log.Printf("error purging old podcasts: %v", err)
|
|
}
|
|
}
|
|
return nil
|
|
}, nil)
|
|
|
|
if *confScanIntervalMins > 0 {
|
|
g.Add(func() error {
|
|
log.Printf("starting job 'scan timer'\n")
|
|
ticker := time.NewTicker(time.Duration(*confScanIntervalMins) * time.Minute)
|
|
for range ticker.C {
|
|
if _, err := scannr.ScanAndClean(scanner.ScanOptions{}); err != nil {
|
|
log.Printf("error scanning: %v", err)
|
|
}
|
|
}
|
|
return nil
|
|
}, nil)
|
|
}
|
|
|
|
if *confScanWatcher {
|
|
g.Add(func() error {
|
|
log.Printf("starting job 'scan watcher'\n")
|
|
return scannr.ExecuteWatch()
|
|
}, func(_ error) {
|
|
scannr.CancelWatch()
|
|
})
|
|
}
|
|
|
|
if jukebx != nil {
|
|
var jukeboxTempDir string
|
|
g.Add(func() error {
|
|
log.Printf("starting job 'jukebox'\n")
|
|
extraArgs, _ := shlex.Split(*confJukeboxMPVExtraArgs)
|
|
var err error
|
|
jukeboxTempDir, err = os.MkdirTemp("", "gonic-jukebox-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create tmp sock file: %w", err)
|
|
}
|
|
sockPath := filepath.Join(jukeboxTempDir, "sock")
|
|
if err := jukebx.Start(sockPath, extraArgs); err != nil {
|
|
return fmt.Errorf("start jukebox: %w", err)
|
|
}
|
|
if err := jukebx.Wait(); err != nil {
|
|
return fmt.Errorf("start jukebox: %w", err)
|
|
}
|
|
return nil
|
|
}, func(_ error) {
|
|
if err := jukebx.Quit(); err != nil {
|
|
log.Printf("error quitting jukebox: %v", err)
|
|
}
|
|
_ = os.RemoveAll(jukeboxTempDir)
|
|
})
|
|
}
|
|
|
|
if *confScanAtStart {
|
|
if _, err := scannr.ScanAndClean(scanner.ScanOptions{}); err != nil {
|
|
log.Panicf("error scanning at start: %v\n", err)
|
|
}
|
|
}
|
|
|
|
if err := g.Run(); err != nil {
|
|
log.Panicf("error in job: %v", err)
|
|
}
|
|
}
|
|
|
|
const pathAliasSep = "->"
|
|
|
|
type pathAliases []pathAlias
|
|
type pathAlias struct{ alias, path string }
|
|
|
|
func (pa pathAliases) String() string {
|
|
var strs []string
|
|
for _, p := range pa {
|
|
if p.alias != "" {
|
|
strs = append(strs, fmt.Sprintf("%s %s %s", p.alias, pathAliasSep, p.path))
|
|
continue
|
|
}
|
|
strs = append(strs, p.path)
|
|
}
|
|
return strings.Join(strs, ", ")
|
|
}
|
|
|
|
func (pa *pathAliases) Set(value string) error {
|
|
if name, path, ok := strings.Cut(value, pathAliasSep); ok {
|
|
*pa = append(*pa, pathAlias{alias: name, path: path})
|
|
return nil
|
|
}
|
|
*pa = append(*pa, pathAlias{path: value})
|
|
return nil
|
|
}
|
|
|
|
func validatePath(p string) (string, error) {
|
|
if p == "" {
|
|
return "", errors.New("path can't be empty")
|
|
}
|
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
|
return "", errors.New("path does not exist, please provide one")
|
|
}
|
|
p, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return "", fmt.Errorf("make absolute: %w", err)
|
|
}
|
|
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
|
|
}
|