package server import ( "fmt" "log" "net/http" "path/filepath" "time" "github.com/gorilla/mux" "github.com/gorilla/securecookie" "github.com/sentriz/gormstore" "go.senan.xyz/gonic/server/assets" "go.senan.xyz/gonic/server/ctrladmin" "go.senan.xyz/gonic/server/ctrlbase" "go.senan.xyz/gonic/server/ctrlsubsonic" "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/jukebox" "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/transcode" ) type Options struct { DB *db.DB MusicPaths []string PodcastPath string CachePath string CoverCachePath string ProxyPrefix string GenreSplit string 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) { for i, musicPath := range opts.MusicPaths { opts.MusicPaths[i] = filepath.Clean(musicPath) } opts.CachePath = filepath.Clean(opts.CachePath) opts.PodcastPath = filepath.Clean(opts.PodcastPath) tagger := &tags.TagReader{} scanner := scanner.New(opts.MusicPaths, opts.DB, opts.GenreSplit, tagger) base := &ctrlbase.Controller{ DB: opts.DB, 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) 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.CachePath, ) ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast) if err != nil { return nil, fmt.Errorf("create admin controller: %w", err) } ctrlSubsonic := &ctrlsubsonic.Controller{ Controller: base, CachePath: opts.CachePath, CoverCachePath: opts.CoverCachePath, PodcastsPath: opts.PodcastPath, MusicPaths: opts.MusicPaths, Jukebox: &jukebox.Jukebox{}, Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}}, 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) }) } 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(assets.Static))) 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_own_username", ctrl.H(ctrl.ServeChangeOwnUsername)) routUser.Handle("/change_own_username_do", ctrl.H(ctrl.ServeChangeOwnUsernameDo)) routUser.Handle("/change_own_password", ctrl.H(ctrl.ServeChangeOwnPassword)) routUser.Handle("/change_own_password_do", ctrl.H(ctrl.ServeChangeOwnPasswordDo)) 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("/upload_playlist_do", ctrl.H(ctrl.ServeUploadPlaylistDo)) routUser.Handle("/delete_playlist_do", ctrl.H(ctrl.ServeDeletePlaylistDo)) 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("/change_username", ctrl.H(ctrl.ServeChangeUsername)) routAdmin.Handle("/change_username_do", ctrl.H(ctrl.ServeChangeUsernameDo)) routAdmin.Handle("/change_password", ctrl.H(ctrl.ServeChangePassword)) routAdmin.Handle("/change_password_do", ctrl.H(ctrl.ServeChangePasswordDo)) routAdmin.Handle("/delete_user", ctrl.H(ctrl.ServeDeleteUser)) routAdmin.Handle("/delete_user_do", ctrl.H(ctrl.ServeDeleteUserDo)) 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)) // 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)) // 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)) // 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)) // 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)) // 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, 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) StartJukebox() (FuncExecute, FuncInterrupt) { return func() error { log.Printf("starting job 'jukebox'\n") return s.jukebox.Listen() }, func(_ error) { // stop job s.jukebox.Quit() } } 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) 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{}{} } }