From b3199de9674ef599d0910070d90c872933a699b2 Mon Sep 17 00:00:00 2001 From: sentriz Date: Mon, 11 Sep 2023 23:47:09 +0100 Subject: [PATCH] remove a whole pile of startup indirection fixes #360 --- cmd/gonic/gonic.go | 218 ++++++++++--- server/ctrladmin/routes.go | 62 ++++ server/ctrlbase/routes.go | 34 ++ server/ctrlsubsonic/ctrl.go | 2 +- server/ctrlsubsonic/handlers_raw.go | 2 +- server/ctrlsubsonic/routes.go | 86 +++++ server/server.go | 465 ---------------------------- 7 files changed, 363 insertions(+), 506 deletions(-) create mode 100644 server/ctrladmin/routes.go create mode 100644 server/ctrlbase/routes.go create mode 100644 server/ctrlsubsonic/routes.go delete mode 100644 server/server.go diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index d156a8d3..fadad2c8 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -1,6 +1,6 @@ // Package main is the gonic server entrypoint // -//nolint:lll // flags help strings +//nolint:lll,gocyclo package main import ( @@ -8,6 +8,7 @@ import ( "flag" "fmt" "log" + "net/http" "os" "path" "path/filepath" @@ -16,19 +17,27 @@ import ( "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/server" + "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" -) - -const ( - cleanTimeDuration = 10 * time.Minute + "go.senan.xyz/gonic/transcode" ) func main() { @@ -146,26 +155,6 @@ func main() { *deprecatedConfGenreSplit = "" } - server, err := server.New(server.Options{ - DB: dbc, - MusicPaths: musicPaths, - ExcludePattern: *confExcludePatterns, - CacheAudioPath: cacheDirAudio, - CoverCachePath: cacheDirCovers, - PodcastPath: *confPodcastPath, - PlaylistsPath: *confPlaylistsPath, - ProxyPrefix: *confProxyPrefix, - MultiValueSettings: map[scanner.Tag]scanner.MultiValueSetting{ - scanner.Genre: scanner.MultiValueSetting(confMultiValueGenre), - scanner.AlbumArtist: scanner.MultiValueSetting(confMultiValueAlbumArtist), - }, - HTTPLog: *confHTTPLog, - JukeboxEnabled: *confJukeboxEnabled, - }) - if err != nil { - log.Panicf("error creating server: %v\n", err) - } - log.Printf("starting gonic v%s\n", gonic.Version) log.Printf("provided config\n") set.VisitAll(func(f *flag.Flag) { @@ -173,26 +162,177 @@ func main() { 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()) + var g run.Group - g.Add(server.StartHTTP(*confListenAddr, *confTLSCert, *confTLSKey)) - g.Add(server.StartSessionClean(cleanTimeDuration)) - g.Add(server.StartPodcastRefresher(time.Hour)) + 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 { - tickerDur := time.Duration(*confScanIntervalMins) * time.Minute - g.Add(server.StartScanTicker(tickerDur)) + 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(server.StartScanWatcher()) - } - if *confJukeboxEnabled { - extraArgs, _ := shlex.Split(*confJukeboxMPVExtraArgs) - g.Add(server.StartJukebox(extraArgs)) + g.Add(func() error { + log.Printf("starting job 'scan watcher'\n") + return scannr.ExecuteWatch() + }, func(_ error) { + scannr.CancelWatch() + }) } - if *confPodcastPurgeAgeDays > 0 { - g.Add(server.StartPodcastPurger(time.Duration(*confPodcastPurgeAgeDays) * 24 * time.Hour)) + + 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 { - server.ScanAtStart() + if _, err := scannr.ScanAndClean(scanner.ScanOptions{}); err != nil { + log.Panicf("error scanning at start: %v\n", err) + } } if err := g.Run(); err != nil { diff --git a/server/ctrladmin/routes.go b/server/ctrladmin/routes.go new file mode 100644 index 00000000..12bda3fe --- /dev/null +++ b/server/ctrladmin/routes.go @@ -0,0 +1,62 @@ +package ctrladmin + +import ( + "net/http" + + "github.com/gorilla/mux" + "go.senan.xyz/gonic/server/ctrladmin/adminui" +) + +func AddRoutes(c *Controller, r *mux.Router) { + // public routes (creates session) + r.Use(c.WithSession) + r.Handle("/login", c.H(c.ServeLogin)) + r.Handle("/login_do", c.HR(c.ServeLoginDo)) // "raw" handler, updates session + + staticHandler := http.StripPrefix("/admin", http.FileServer(http.FS(adminui.StaticFS))) + r.PathPrefix("/static").Handler(staticHandler) + + // user routes (if session is valid) + routUser := r.NewRoute().Subrouter() + routUser.Use(c.WithUserSession) + routUser.Handle("/logout", c.HR(c.ServeLogout)) // "raw" handler, updates session + routUser.Handle("/home", c.H(c.ServeHome)) + routUser.Handle("/change_username", c.H(c.ServeChangeUsername)) + routUser.Handle("/change_username_do", c.H(c.ServeChangeUsernameDo)) + routUser.Handle("/change_password", c.H(c.ServeChangePassword)) + routUser.Handle("/change_password_do", c.H(c.ServeChangePasswordDo)) + routUser.Handle("/change_avatar", c.H(c.ServeChangeAvatar)) + routUser.Handle("/change_avatar_do", c.H(c.ServeChangeAvatarDo)) + routUser.Handle("/delete_avatar_do", c.H(c.ServeDeleteAvatarDo)) + routUser.Handle("/delete_user", c.H(c.ServeDeleteUser)) + routUser.Handle("/delete_user_do", c.H(c.ServeDeleteUserDo)) + routUser.Handle("/link_lastfm_do", c.H(c.ServeLinkLastFMDo)) + routUser.Handle("/unlink_lastfm_do", c.H(c.ServeUnlinkLastFMDo)) + routUser.Handle("/link_listenbrainz_do", c.H(c.ServeLinkListenBrainzDo)) + routUser.Handle("/unlink_listenbrainz_do", c.H(c.ServeUnlinkListenBrainzDo)) + routUser.Handle("/create_transcode_pref_do", c.H(c.ServeCreateTranscodePrefDo)) + routUser.Handle("/delete_transcode_pref_do", c.H(c.ServeDeleteTranscodePrefDo)) + + // admin routes (if session is valid, and is admin) + routAdmin := routUser.NewRoute().Subrouter() + routAdmin.Use(c.WithAdminSession) + routAdmin.Handle("/create_user", c.H(c.ServeCreateUser)) + routAdmin.Handle("/create_user_do", c.H(c.ServeCreateUserDo)) + routAdmin.Handle("/update_lastfm_api_key", c.H(c.ServeUpdateLastFMAPIKey)) + routAdmin.Handle("/update_lastfm_api_key_do", c.H(c.ServeUpdateLastFMAPIKeyDo)) + routAdmin.Handle("/start_scan_inc_do", c.H(c.ServeStartScanIncDo)) + routAdmin.Handle("/start_scan_full_do", c.H(c.ServeStartScanFullDo)) + routAdmin.Handle("/add_podcast_do", c.H(c.ServePodcastAddDo)) + routAdmin.Handle("/delete_podcast_do", c.H(c.ServePodcastDeleteDo)) + routAdmin.Handle("/download_podcast_do", c.H(c.ServePodcastDownloadDo)) + routAdmin.Handle("/update_podcast_do", c.H(c.ServePodcastUpdateDo)) + routAdmin.Handle("/add_internet_radio_station_do", c.H(c.ServeInternetRadioStationAddDo)) + routAdmin.Handle("/delete_internet_radio_station_do", c.H(c.ServeInternetRadioStationDeleteDo)) + routAdmin.Handle("/update_internet_radio_station_do", c.H(c.ServeInternetRadioStationUpdateDo)) + + // middlewares should be run for not found handler + // https://github.com/gorilla/mux/issues/416 + notFoundHandler := c.H(c.ServeNotFound) + notFoundRoute := r.NewRoute().Handler(notFoundHandler) + r.NotFoundHandler = notFoundRoute.GetHandler() +} diff --git a/server/ctrlbase/routes.go b/server/ctrlbase/routes.go new file mode 100644 index 00000000..7ade2f19 --- /dev/null +++ b/server/ctrlbase/routes.go @@ -0,0 +1,34 @@ +package ctrlbase + +import ( + "fmt" + "net/http" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" +) + +func AddRoutes(c *Controller, r *mux.Router, logHTTP bool) { + if logHTTP { + r.Use(c.WithLogging) + } + r.Use(c.WithCORS) + r.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))) + + r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + adminHome := c.Path("/admin/home") + http.Redirect(w, r, adminHome, http.StatusSeeOther) + }) + // misc subsonic routes without /rest prefix + r.HandleFunc("/settings.view", func(w http.ResponseWriter, r *http.Request) { + adminHome := c.Path("/admin/home") + http.Redirect(w, r, adminHome, http.StatusSeeOther) + }) + r.HandleFunc("/musicFolderSettings.view", func(w http.ResponseWriter, r *http.Request) { + restScan := c.Path(fmt.Sprintf("/rest/startScan.view?%s", r.URL.Query().Encode())) + http.Redirect(w, r, restScan, http.StatusSeeOther) + }) + r.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "OK") + }) +} diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index f06af5e3..feb9dad6 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -44,7 +44,7 @@ type Controller struct { MusicPaths []MusicPath PodcastsPath string CacheAudioPath string - CoverCachePath string + CacheCoverPath string Jukebox *jukebox.Jukebox Scrobblers []scrobble.Scrobbler Podcasts *podcasts.Podcasts diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index 1cf63643..ed2e7dfc 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -229,7 +229,7 @@ func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *s } size := params.GetOrInt("size", coverDefaultSize) cachePath := path.Join( - c.CoverCachePath, + c.CacheCoverPath, fmt.Sprintf("%s-%d.%s", id.String(), size, coverCacheFormat), ) _, err = os.Stat(cachePath) diff --git a/server/ctrlsubsonic/routes.go b/server/ctrlsubsonic/routes.go new file mode 100644 index 00000000..1770614a --- /dev/null +++ b/server/ctrlsubsonic/routes.go @@ -0,0 +1,86 @@ +package ctrlsubsonic + +import "github.com/gorilla/mux" + +func AddRoutes(c *Controller, r *mux.Router) { + r.Use(c.WithParams) + r.Use(c.WithRequiredParams) + r.Use(c.WithUser) + + // common + r.Handle("/getLicense{_:(?:\\.view)?}", c.H(c.ServeGetLicence)) + r.Handle("/getMusicFolders{_:(?:\\.view)?}", c.H(c.ServeGetMusicFolders)) + r.Handle("/getScanStatus{_:(?:\\.view)?}", c.H(c.ServeGetScanStatus)) + r.Handle("/ping{_:(?:\\.view)?}", c.H(c.ServePing)) + r.Handle("/scrobble{_:(?:\\.view)?}", c.H(c.ServeScrobble)) + r.Handle("/startScan{_:(?:\\.view)?}", c.H(c.ServeStartScan)) + r.Handle("/getUser{_:(?:\\.view)?}", c.H(c.ServeGetUser)) + r.Handle("/getPlaylists{_:(?:\\.view)?}", c.H(c.ServeGetPlaylists)) + r.Handle("/getPlaylist{_:(?:\\.view)?}", c.H(c.ServeGetPlaylist)) + r.Handle("/createPlaylist{_:(?:\\.view)?}", c.H(c.ServeCreatePlaylist)) + r.Handle("/updatePlaylist{_:(?:\\.view)?}", c.H(c.ServeUpdatePlaylist)) + r.Handle("/deletePlaylist{_:(?:\\.view)?}", c.H(c.ServeDeletePlaylist)) + r.Handle("/savePlayQueue{_:(?:\\.view)?}", c.H(c.ServeSavePlayQueue)) + r.Handle("/getPlayQueue{_:(?:\\.view)?}", c.H(c.ServeGetPlayQueue)) + r.Handle("/getSong{_:(?:\\.view)?}", c.H(c.ServeGetSong)) + r.Handle("/getRandomSongs{_:(?:\\.view)?}", c.H(c.ServeGetRandomSongs)) + r.Handle("/getSongsByGenre{_:(?:\\.view)?}", c.H(c.ServeGetSongsByGenre)) + r.Handle("/jukeboxControl{_:(?:\\.view)?}", c.H(c.ServeJukebox)) + r.Handle("/getBookmarks{_:(?:\\.view)?}", c.H(c.ServeGetBookmarks)) + r.Handle("/createBookmark{_:(?:\\.view)?}", c.H(c.ServeCreateBookmark)) + r.Handle("/deleteBookmark{_:(?:\\.view)?}", c.H(c.ServeDeleteBookmark)) + r.Handle("/getTopSongs{_:(?:\\.view)?}", c.H(c.ServeGetTopSongs)) + r.Handle("/getSimilarSongs{_:(?:\\.view)?}", c.H(c.ServeGetSimilarSongs)) + r.Handle("/getSimilarSongs2{_:(?:\\.view)?}", c.H(c.ServeGetSimilarSongsTwo)) + r.Handle("/getLyrics{_:(?:\\.view)?}", c.H(c.ServeGetLyrics)) + + // raw + r.Handle("/getCoverArt{_:(?:\\.view)?}", c.HR(c.ServeGetCoverArt)) + r.Handle("/stream{_:(?:\\.view)?}", c.HR(c.ServeStream)) + r.Handle("/download{_:(?:\\.view)?}", c.HR(c.ServeStream)) + r.Handle("/getAvatar{_:(?:\\.view)?}", c.HR(c.ServeGetAvatar)) + + // browse by tag + r.Handle("/getAlbum{_:(?:\\.view)?}", c.H(c.ServeGetAlbum)) + r.Handle("/getAlbumList2{_:(?:\\.view)?}", c.H(c.ServeGetAlbumListTwo)) + r.Handle("/getArtist{_:(?:\\.view)?}", c.H(c.ServeGetArtist)) + r.Handle("/getArtists{_:(?:\\.view)?}", c.H(c.ServeGetArtists)) + r.Handle("/search3{_:(?:\\.view)?}", c.H(c.ServeSearchThree)) + r.Handle("/getArtistInfo2{_:(?:\\.view)?}", c.H(c.ServeGetArtistInfoTwo)) + r.Handle("/getStarred2{_:(?:\\.view)?}", c.H(c.ServeGetStarredTwo)) + + // browse by folder + r.Handle("/getIndexes{_:(?:\\.view)?}", c.H(c.ServeGetIndexes)) + r.Handle("/getMusicDirectory{_:(?:\\.view)?}", c.H(c.ServeGetMusicDirectory)) + r.Handle("/getAlbumList{_:(?:\\.view)?}", c.H(c.ServeGetAlbumList)) + r.Handle("/search2{_:(?:\\.view)?}", c.H(c.ServeSearchTwo)) + r.Handle("/getGenres{_:(?:\\.view)?}", c.H(c.ServeGetGenres)) + r.Handle("/getArtistInfo{_:(?:\\.view)?}", c.H(c.ServeGetArtistInfo)) + r.Handle("/getStarred{_:(?:\\.view)?}", c.H(c.ServeGetStarred)) + + // star / rating + r.Handle("/star{_:(?:\\.view)?}", c.H(c.ServeStar)) + r.Handle("/unstar{_:(?:\\.view)?}", c.H(c.ServeUnstar)) + r.Handle("/setRating{_:(?:\\.view)?}", c.H(c.ServeSetRating)) + + // podcasts + r.Handle("/getPodcasts{_:(?:\\.view)?}", c.H(c.ServeGetPodcasts)) + r.Handle("/getNewestPodcasts{_:(?:\\.view)?}", c.H(c.ServeGetNewestPodcasts)) + r.Handle("/downloadPodcastEpisode{_:(?:\\.view)?}", c.H(c.ServeDownloadPodcastEpisode)) + r.Handle("/createPodcastChannel{_:(?:\\.view)?}", c.H(c.ServeCreatePodcastChannel)) + r.Handle("/refreshPodcasts{_:(?:\\.view)?}", c.H(c.ServeRefreshPodcasts)) + r.Handle("/deletePodcastChannel{_:(?:\\.view)?}", c.H(c.ServeDeletePodcastChannel)) + r.Handle("/deletePodcastEpisode{_:(?:\\.view)?}", c.H(c.ServeDeletePodcastEpisode)) + + // internet radio + r.Handle("/getInternetRadioStations{_:(?:\\.view)?}", c.H(c.ServeGetInternetRadioStations)) + r.Handle("/createInternetRadioStation{_:(?:\\.view)?}", c.H(c.ServeCreateInternetRadioStation)) + r.Handle("/updateInternetRadioStation{_:(?:\\.view)?}", c.H(c.ServeUpdateInternetRadioStation)) + r.Handle("/deleteInternetRadioStation{_:(?:\\.view)?}", c.H(c.ServeDeleteInternetRadioStation)) + + // middlewares should be run for not found handler + // https://github.com/gorilla/mux/issues/416 + notFoundHandler := c.H(c.ServeNotFound) + notFoundRoute := r.NewRoute().Handler(notFoundHandler) + r.NotFoundHandler = notFoundRoute.GetHandler() +} diff --git a/server/server.go b/server/server.go deleted file mode 100644 index 0e3790c5..00000000 --- a/server/server.go +++ /dev/null @@ -1,465 +0,0 @@ -package server - -import ( - "fmt" - "log" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/gorilla/handlers" - "github.com/gorilla/mux" - "github.com/gorilla/securecookie" - "github.com/sentriz/gormstore" - - "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/ctrladmin/adminui" - "go.senan.xyz/gonic/server/ctrlbase" - "go.senan.xyz/gonic/server/ctrlsubsonic" - "go.senan.xyz/gonic/transcode" -) - -type Options struct { - DB *db.DB - MusicPaths []ctrlsubsonic.MusicPath - ExcludePattern string - PodcastPath string - CacheAudioPath string - CoverCachePath string - PlaylistsPath string - ProxyPrefix string - MultiValueSettings map[scanner.Tag]scanner.MultiValueSetting - HTTPLog bool - JukeboxEnabled bool -} - -type Server struct { - scanner *scanner.Scanner - jukebox *jukebox.Jukebox - router *mux.Router - sessDB *gormstore.Store - podcast *podcasts.Podcasts -} - -func New(opts Options) (*Server, error) { - tagger := &tags.TagReader{} - - scanner := scanner.New(ctrlsubsonic.PathsOf(opts.MusicPaths), opts.DB, opts.MultiValueSettings, tagger, opts.ExcludePattern) - - playlistStore, err := playlist.NewStore(opts.PlaylistsPath) - if err != nil { - return nil, fmt.Errorf("create playlists store: %w", err) - } - - base := &ctrlbase.Controller{ - DB: opts.DB, - PlaylistStore: playlistStore, - ProxyPrefix: opts.ProxyPrefix, - Scanner: scanner, - } - - // router with common wares for admin / subsonic - r := mux.NewRouter() - if opts.HTTPLog { - r.Use(base.WithLogging) - } - r.Use(base.WithCORS) - r.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))) - - sessKey, err := opts.DB.GetSetting("session_key") - if err != nil { - return nil, fmt.Errorf("get session key: %w", err) - } - if sessKey == "" { - if err := opts.DB.SetSetting("session_key", string(securecookie.GenerateRandomKey(32))); err != nil { - return nil, fmt.Errorf("set session key: %w", err) - } - } - - sessDB := gormstore.New(opts.DB.DB, []byte(sessKey)) - sessDB.SessionOpts.HttpOnly = true - sessDB.SessionOpts.SameSite = http.SameSiteLaxMode - - podcast := podcasts.New(opts.DB, opts.PodcastPath, tagger) - - cacheTranscoder := transcode.NewCachingTranscoder( - transcode.NewFFmpegTranscoder(), - opts.CacheAudioPath, - ) - - lastfmClient := lastfm.NewClient() - - ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast, lastfmClient) - if err != nil { - return nil, fmt.Errorf("create admin controller: %w", err) - } - - ctrlSubsonic := &ctrlsubsonic.Controller{ - Controller: base, - MusicPaths: opts.MusicPaths, - PodcastsPath: opts.PodcastPath, - CacheAudioPath: opts.CacheAudioPath, - CoverCachePath: opts.CoverCachePath, - LastFMClient: lastfmClient, - Scrobblers: []scrobble.Scrobbler{ - lastfm.NewScrobbler(opts.DB, lastfmClient), - listenbrainz.NewScrobbler(), - }, - Podcasts: podcast, - Transcoder: cacheTranscoder, - } - - setupMisc(r, base) - setupAdmin(r.PathPrefix("/admin").Subrouter(), ctrlAdmin) - setupSubsonic(r.PathPrefix("/rest").Subrouter(), ctrlSubsonic) - - server := &Server{ - scanner: scanner, - router: r, - sessDB: sessDB, - podcast: podcast, - } - - if opts.JukeboxEnabled { - jukebox := jukebox.New() - ctrlSubsonic.Jukebox = jukebox - server.jukebox = jukebox - } - - return server, nil -} - -func setupMisc(r *mux.Router, ctrl *ctrlbase.Controller) { - r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - adminHome := ctrl.Path("/admin/home") - http.Redirect(w, r, adminHome, http.StatusSeeOther) - }) - // misc subsonic routes without /rest prefix - r.HandleFunc("/settings.view", func(w http.ResponseWriter, r *http.Request) { - adminHome := ctrl.Path("/admin/home") - http.Redirect(w, r, adminHome, http.StatusSeeOther) - }) - r.HandleFunc("/musicFolderSettings.view", func(w http.ResponseWriter, r *http.Request) { - restScan := ctrl.Path(fmt.Sprintf("/rest/startScan.view?%s", r.URL.Query().Encode())) - http.Redirect(w, r, restScan, http.StatusSeeOther) - }) - r.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, "OK") - }) -} - -func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) { - - // public routes (creates session) - r.Use(ctrl.WithSession) - r.Handle("/login", ctrl.H(ctrl.ServeLogin)) - r.Handle("/login_do", ctrl.HR(ctrl.ServeLoginDo)) // "raw" handler, updates session - - staticHandler := http.StripPrefix("/admin", http.FileServer(http.FS(adminui.StaticFS))) - r.PathPrefix("/static").Handler(staticHandler) - - // user routes (if session is valid) - routUser := r.NewRoute().Subrouter() - routUser.Use(ctrl.WithUserSession) - routUser.Handle("/logout", ctrl.HR(ctrl.ServeLogout)) // "raw" handler, updates session - routUser.Handle("/home", ctrl.H(ctrl.ServeHome)) - routUser.Handle("/change_username", ctrl.H(ctrl.ServeChangeUsername)) - routUser.Handle("/change_username_do", ctrl.H(ctrl.ServeChangeUsernameDo)) - routUser.Handle("/change_password", ctrl.H(ctrl.ServeChangePassword)) - routUser.Handle("/change_password_do", ctrl.H(ctrl.ServeChangePasswordDo)) - routUser.Handle("/change_avatar", ctrl.H(ctrl.ServeChangeAvatar)) - routUser.Handle("/change_avatar_do", ctrl.H(ctrl.ServeChangeAvatarDo)) - routUser.Handle("/delete_avatar_do", ctrl.H(ctrl.ServeDeleteAvatarDo)) - routUser.Handle("/delete_user", ctrl.H(ctrl.ServeDeleteUser)) - routUser.Handle("/delete_user_do", ctrl.H(ctrl.ServeDeleteUserDo)) - routUser.Handle("/link_lastfm_do", ctrl.H(ctrl.ServeLinkLastFMDo)) - routUser.Handle("/unlink_lastfm_do", ctrl.H(ctrl.ServeUnlinkLastFMDo)) - routUser.Handle("/link_listenbrainz_do", ctrl.H(ctrl.ServeLinkListenBrainzDo)) - routUser.Handle("/unlink_listenbrainz_do", ctrl.H(ctrl.ServeUnlinkListenBrainzDo)) - routUser.Handle("/create_transcode_pref_do", ctrl.H(ctrl.ServeCreateTranscodePrefDo)) - routUser.Handle("/delete_transcode_pref_do", ctrl.H(ctrl.ServeDeleteTranscodePrefDo)) - - // admin routes (if session is valid, and is admin) - routAdmin := routUser.NewRoute().Subrouter() - routAdmin.Use(ctrl.WithAdminSession) - routAdmin.Handle("/create_user", ctrl.H(ctrl.ServeCreateUser)) - routAdmin.Handle("/create_user_do", ctrl.H(ctrl.ServeCreateUserDo)) - routAdmin.Handle("/update_lastfm_api_key", ctrl.H(ctrl.ServeUpdateLastFMAPIKey)) - routAdmin.Handle("/update_lastfm_api_key_do", ctrl.H(ctrl.ServeUpdateLastFMAPIKeyDo)) - routAdmin.Handle("/start_scan_inc_do", ctrl.H(ctrl.ServeStartScanIncDo)) - routAdmin.Handle("/start_scan_full_do", ctrl.H(ctrl.ServeStartScanFullDo)) - routAdmin.Handle("/add_podcast_do", ctrl.H(ctrl.ServePodcastAddDo)) - routAdmin.Handle("/delete_podcast_do", ctrl.H(ctrl.ServePodcastDeleteDo)) - routAdmin.Handle("/download_podcast_do", ctrl.H(ctrl.ServePodcastDownloadDo)) - routAdmin.Handle("/update_podcast_do", ctrl.H(ctrl.ServePodcastUpdateDo)) - routAdmin.Handle("/add_internet_radio_station_do", ctrl.H(ctrl.ServeInternetRadioStationAddDo)) - routAdmin.Handle("/delete_internet_radio_station_do", ctrl.H(ctrl.ServeInternetRadioStationDeleteDo)) - routAdmin.Handle("/update_internet_radio_station_do", ctrl.H(ctrl.ServeInternetRadioStationUpdateDo)) - - // middlewares should be run for not found handler - // https://github.com/gorilla/mux/issues/416 - notFoundHandler := ctrl.H(ctrl.ServeNotFound) - notFoundRoute := r.NewRoute().Handler(notFoundHandler) - r.NotFoundHandler = notFoundRoute.GetHandler() -} - -func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { - r.Use(ctrl.WithParams) - r.Use(ctrl.WithRequiredParams) - r.Use(ctrl.WithUser) - - // common - r.Handle("/getLicense{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetLicence)) - r.Handle("/getMusicFolders{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetMusicFolders)) - r.Handle("/getScanStatus{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetScanStatus)) - r.Handle("/ping{_:(?:\\.view)?}", ctrl.H(ctrl.ServePing)) - r.Handle("/scrobble{_:(?:\\.view)?}", ctrl.H(ctrl.ServeScrobble)) - r.Handle("/startScan{_:(?:\\.view)?}", ctrl.H(ctrl.ServeStartScan)) - r.Handle("/getUser{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetUser)) - r.Handle("/getPlaylists{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPlaylists)) - r.Handle("/getPlaylist{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPlaylist)) - r.Handle("/createPlaylist{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreatePlaylist)) - r.Handle("/updatePlaylist{_:(?:\\.view)?}", ctrl.H(ctrl.ServeUpdatePlaylist)) - r.Handle("/deletePlaylist{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePlaylist)) - r.Handle("/savePlayQueue{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSavePlayQueue)) - r.Handle("/getPlayQueue{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPlayQueue)) - r.Handle("/getSong{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSong)) - r.Handle("/getRandomSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetRandomSongs)) - r.Handle("/getSongsByGenre{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSongsByGenre)) - r.Handle("/jukeboxControl{_:(?:\\.view)?}", ctrl.H(ctrl.ServeJukebox)) - r.Handle("/getBookmarks{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetBookmarks)) - r.Handle("/createBookmark{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreateBookmark)) - r.Handle("/deleteBookmark{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeleteBookmark)) - r.Handle("/getTopSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetTopSongs)) - r.Handle("/getSimilarSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSimilarSongs)) - r.Handle("/getSimilarSongs2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSimilarSongsTwo)) - r.Handle("/getLyrics{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetLyrics)) - - // raw - r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt)) - r.Handle("/stream{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream)) - r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream)) - r.Handle("/getAvatar{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetAvatar)) - - // browse by tag - r.Handle("/getAlbum{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbum)) - r.Handle("/getAlbumList2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbumListTwo)) - r.Handle("/getArtist{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtist)) - r.Handle("/getArtists{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtists)) - r.Handle("/search3{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSearchThree)) - r.Handle("/getArtistInfo2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtistInfoTwo)) - r.Handle("/getStarred2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetStarredTwo)) - - // browse by folder - r.Handle("/getIndexes{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetIndexes)) - r.Handle("/getMusicDirectory{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetMusicDirectory)) - r.Handle("/getAlbumList{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbumList)) - r.Handle("/search2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSearchTwo)) - r.Handle("/getGenres{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetGenres)) - r.Handle("/getArtistInfo{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtistInfo)) - r.Handle("/getStarred{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetStarred)) - - // star / rating - r.Handle("/star{_:(?:\\.view)?}", ctrl.H(ctrl.ServeStar)) - r.Handle("/unstar{_:(?:\\.view)?}", ctrl.H(ctrl.ServeUnstar)) - r.Handle("/setRating{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSetRating)) - - // podcasts - r.Handle("/getPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPodcasts)) - r.Handle("/getNewestPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetNewestPodcasts)) - r.Handle("/downloadPodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDownloadPodcastEpisode)) - r.Handle("/createPodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreatePodcastChannel)) - r.Handle("/refreshPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeRefreshPodcasts)) - r.Handle("/deletePodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastChannel)) - r.Handle("/deletePodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastEpisode)) - - // internet radio - r.Handle("/getInternetRadioStations{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetInternetRadioStations)) - r.Handle("/createInternetRadioStation{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreateInternetRadioStation)) - r.Handle("/updateInternetRadioStation{_:(?:\\.view)?}", ctrl.H(ctrl.ServeUpdateInternetRadioStation)) - r.Handle("/deleteInternetRadioStation{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeleteInternetRadioStation)) - - // middlewares should be run for not found handler - // https://github.com/gorilla/mux/issues/416 - notFoundHandler := ctrl.H(ctrl.ServeNotFound) - notFoundRoute := r.NewRoute().Handler(notFoundHandler) - r.NotFoundHandler = notFoundRoute.GetHandler() -} - -type ( - FuncExecute func() error - FuncInterrupt func(error) -) - -func (s *Server) StartHTTP(listenAddr string, tlsCert string, tlsKey string) (FuncExecute, FuncInterrupt) { - list := &http.Server{ - Addr: listenAddr, - Handler: s.router, - ReadTimeout: 5 * time.Second, - ReadHeaderTimeout: 5 * time.Second, - WriteTimeout: 80 * time.Second, - IdleTimeout: 60 * time.Second, - } - return func() error { - log.Print("starting job 'http'\n") - if tlsCert != "" && tlsKey != "" { - return list.ListenAndServeTLS(tlsCert, tlsKey) - } - return list.ListenAndServe() - }, func(_ error) { - // stop job - _ = list.Close() - } -} - -func (s *Server) StartScanTicker(dur time.Duration) (FuncExecute, FuncInterrupt) { - ticker := time.NewTicker(dur) - done := make(chan struct{}) - waitFor := func() error { - for { - select { - case <-done: - return nil - case <-ticker.C: - go func() { - if _, err := s.scanner.ScanAndClean(scanner.ScanOptions{}); err != nil { - log.Printf("error scanning: %v", err) - } - }() - } - } - } - return func() error { - log.Printf("starting job 'scan timer'\n") - return waitFor() - }, func(_ error) { - // stop job - ticker.Stop() - done <- struct{}{} - } -} - -func (s *Server) ScanAtStart() { - if _, err := s.scanner.ScanAndClean(scanner.ScanOptions{}); err != nil { - log.Printf("error scanning: %v", err) - } -} - -func (s *Server) StartScanWatcher() (FuncExecute, FuncInterrupt) { - return func() error { - log.Printf("starting job 'scan watcher'\n") - return s.scanner.ExecuteWatch() - }, func(_ error) { - // stop job - s.scanner.CancelWatch() - } -} - -func (s *Server) StartJukebox(mpvExtraArgs []string) (FuncExecute, FuncInterrupt) { - var tempDir string - return func() error { - log.Printf("starting job 'jukebox'\n") - var err error - tempDir, err = os.MkdirTemp("", "gonic-jukebox-*") - if err != nil { - return fmt.Errorf("create tmp sock file: %w", err) - } - sockPath := filepath.Join(tempDir, "sock") - if err := s.jukebox.Start(sockPath, mpvExtraArgs); err != nil { - return fmt.Errorf("start jukebox: %w", err) - } - if err := s.jukebox.Wait(); err != nil { - return fmt.Errorf("start jukebox: %w", err) - } - return nil - }, func(_ error) { - // stop job - if err := s.jukebox.Quit(); err != nil { - log.Printf("error quitting jukebox: %v", err) - } - _ = os.RemoveAll(tempDir) - } -} - -func (s *Server) StartPodcastRefresher(dur time.Duration) (FuncExecute, FuncInterrupt) { - ticker := time.NewTicker(dur) - done := make(chan struct{}) - waitFor := func() error { - for { - select { - case <-done: - return nil - case <-ticker.C: - if err := s.podcast.RefreshPodcasts(); err != nil { - log.Printf("failed to refresh some feeds: %s", err) - } - } - } - } - return func() error { - log.Printf("starting job 'podcast refresher'\n") - return waitFor() - }, func(_ error) { - // stop job - ticker.Stop() - done <- struct{}{} - } -} - -func (s *Server) StartPodcastPurger(maxAge time.Duration) (FuncExecute, FuncInterrupt) { - ticker := time.NewTicker(24 * time.Hour) - done := make(chan struct{}) - waitFor := func() error { - for { - select { - case <-done: - return nil - case <-ticker.C: - if err := s.podcast.PurgeOldPodcasts(maxAge); err != nil { - log.Printf("error purging old podcasts: %v", err) - } - } - } - } - return func() error { - log.Printf("starting job 'podcast purger'\n") - return waitFor() - }, func(_ error) { - // stop job - ticker.Stop() - done <- struct{}{} - } -} - -func (s *Server) StartSessionClean(dur time.Duration) (FuncExecute, FuncInterrupt) { - ticker := time.NewTicker(dur) - done := make(chan struct{}) - waitFor := func() error { - for { - select { - case <-done: - return nil - case <-ticker.C: - s.sessDB.Cleanup() - } - } - } - return func() error { - log.Printf("starting job 'session clean'\n") - return waitFor() - }, func(_ error) { - // stop job - ticker.Stop() - done <- struct{}{} - } -}