diff --git a/.gitignore b/.gitignore index 80d841f..c781db5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ *.db *-packr.go packrd/ -scanner -server cmd/scanner/scanner cmd/server/server _test* diff --git a/cmd/scanner/main.go b/cmd/scanner/main.go index e190a6a..7096a1b 100644 --- a/cmd/scanner/main.go +++ b/cmd/scanner/main.go @@ -1,245 +1,46 @@ -// this scanner tries to scan with a single unsorted walk of the music -// directory - which means you can come across the cover of an album/folder -// before the tracks (and therefore the album) which is an issue because -// when inserting into the album table, we need a reference to the cover. -// to solve this we're using godirwalk's PostChildrenCallback and some globals -// -// Album -> needs a CoverID -// -> needs a FolderID (American Football) -// Folder -> needs a CoverID -// -> needs a ParentID -// Track -> needs an AlbumID -// -> needs a FolderID - package main import ( - "fmt" - "io/ioutil" + "flag" "log" "os" - "path" - "time" - "github.com/dhowden/tag" "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/sqlite" - "github.com/karrick/godirwalk" + "github.com/peterbourgon/ff" - "github.com/sentriz/gonic/db" + "github.com/sentriz/gonic/scanner" ) -var ( - orm *gorm.DB - tx *gorm.DB - // seenPaths is used to keep every path we've seen so that - // we can remove old tracks, folders, and covers by path when we - // are in the cleanDatabase stage - seenPaths = make(map[string]bool) - // currentDirStack is used for inserting to the folders (subsonic browse - // by folder) which helps us work out a folder's parent - currentDirStack = make(dirStack, 0) - // currentCover because we find a cover anywhere among the tracks during the - // walk and need a reference to it when we update folder and album records - // when we exit a folder - currentCover = &db.Cover{} - // currentAlbum because we update this record when we exit a folder with - // our new reference to it's cover - currentAlbum = &db.Album{} +const ( + programName = "gonic" + programVar = "GONIC" ) -func readTags(fullPath string) (tag.Metadata, error) { - trackData, err := os.Open(fullPath) - if err != nil { - return nil, fmt.Errorf("when tags from disk: %v", err) - } - defer trackData.Close() - tags, err := tag.ReadFrom(trackData) - if err != nil { - return nil, err - } - return tags, nil -} - -func updateAlbum(fullPath string, album *db.Album) { - if currentAlbum.ID != 0 { - return - } - directory, _ := path.Split(fullPath) - // update album table (the currentAlbum record will be updated when - // we exit this folder) - err := tx.Where("path = ?", directory).First(currentAlbum).Error - if !gorm.IsRecordNotFoundError(err) { - // we found the record - // TODO: think about mod time here - return - } - currentAlbum = &db.Album{ - Path: directory, - Title: album.Title, - AlbumArtistID: album.AlbumArtistID, - Year: album.Year, - } - tx.Save(currentAlbum) -} - -func handleCover(fullPath string, stat os.FileInfo) error { - modTime := stat.ModTime() - err := tx.Where("path = ?", fullPath).First(currentCover).Error - if !gorm.IsRecordNotFoundError(err) && - modTime.Before(currentCover.UpdatedAt) { - // we found the record but it hasn't changed - return nil - } - image, err := ioutil.ReadFile(fullPath) - if err != nil { - return fmt.Errorf("when reading cover: %v", err) - } - currentCover = &db.Cover{ - Path: fullPath, - Image: image, - NewlyInserted: true, - } - tx.Save(currentCover) - return nil -} - -func handleFolder(fullPath string, stat os.FileInfo) error { - // update folder table for browsing by folder - folder := &db.Folder{} - defer currentDirStack.Push(folder) - modTime := stat.ModTime() - err := tx.Where("path = ?", fullPath).First(folder).Error - if !gorm.IsRecordNotFoundError(err) && - modTime.Before(folder.UpdatedAt) { - // we found the record but it hasn't changed - return nil - } - _, folderName := path.Split(fullPath) - folder.Path = fullPath - folder.ParentID = currentDirStack.PeekID() - folder.Name = folderName - tx.Save(folder) - return nil -} - -func handleFolderCompletion(fullPath string, info *godirwalk.Dirent) error { - currentDir := currentDirStack.Peek() - defer currentDirStack.Pop() - var dirShouldSave bool - if currentAlbum.ID != 0 { - currentAlbum.CoverID = currentCover.ID - tx.Save(currentAlbum) - currentDir.HasTracks = true - dirShouldSave = true - } - if currentCover.NewlyInserted { - currentDir.CoverID = currentCover.ID - dirShouldSave = true - } - if dirShouldSave { - tx.Save(currentDir) - } - currentCover = &db.Cover{} - currentAlbum = &db.Album{} - log.Printf("processed folder `%s`\n", fullPath) - return nil -} - -func handleTrack(fullPath string, stat os.FileInfo, mime, exten string) error { - // - // set track basics - track := &db.Track{} - modTime := stat.ModTime() - err := tx.Where("path = ?", fullPath).First(track).Error - if !gorm.IsRecordNotFoundError(err) && - modTime.Before(track.UpdatedAt) { - // we found the record but it hasn't changed - return nil - } - tags, err := readTags(fullPath) - if err != nil { - return fmt.Errorf("when reading tags: %v", err) - } - trackNumber, totalTracks := tags.Track() - discNumber, totalDiscs := tags.Disc() - track.Path = fullPath - track.Title = tags.Title() - track.Artist = tags.Artist() - track.DiscNumber = discNumber - track.TotalDiscs = totalDiscs - track.TotalTracks = totalTracks - track.TrackNumber = trackNumber - track.Year = tags.Year() - track.Suffix = exten - track.ContentType = mime - track.Size = int(stat.Size()) - track.FolderID = currentDirStack.PeekID() - // - // set album artist basics - albumArtist := &db.AlbumArtist{} - err = tx.Where("name = ?", tags.AlbumArtist()). - First(albumArtist). - Error - if gorm.IsRecordNotFoundError(err) { - albumArtist.Name = tags.AlbumArtist() - tx.Save(albumArtist) - } - track.AlbumArtistID = albumArtist.ID - // - // set temporary album's basics - will be updated with - // cover after the tracks inserted when we exit the folder - updateAlbum(fullPath, &db.Album{ - AlbumArtistID: albumArtist.ID, - Title: tags.Album(), - Year: tags.Year(), - }) - // - // update the track with our new album and finally save - track.AlbumID = currentAlbum.ID - tx.Save(track) - return nil -} - -func handleItem(fullPath string, info *godirwalk.Dirent) error { - // seenPaths = append(seenPaths, fullPath) - seenPaths[fullPath] = true - stat, err := os.Stat(fullPath) - if err != nil { - return fmt.Errorf("error stating: %v", err) - } - if info.IsDir() { - return handleFolder(fullPath, stat) - } - if isCover(fullPath) { - return handleCover(fullPath, stat) - } - if mime, exten, ok := isAudio(fullPath); ok { - return handleTrack(fullPath, stat, mime, exten) - } - return nil -} - func main() { - if len(os.Args) != 2 { - log.Fatalf("usage: %s ", os.Args[0]) - } - orm = db.New() - orm.SetLogger(log.New(os.Stdout, "gorm ", 0)) - tx = orm.Begin() - createDatabase() - startTime := time.Now() - err := godirwalk.Walk(os.Args[1], &godirwalk.Options{ - Callback: handleItem, - PostChildrenCallback: handleFolderCompletion, - Unsorted: true, - }) + set := flag.NewFlagSet(programName, flag.ExitOnError) + musicPath := set.String( + "music-path", "", + "path to music") + dbPath := set.String( + "db-path", "gonic.db", + "path to database (optional)") + err := ff.Parse(set, os.Args[1:]) if err != nil { - log.Fatalf("error when walking: %v\n", err) + log.Fatalf("error parsing args: %v\n", err) } - log.Printf("scanned in %s\n", time.Since(startTime)) - startTime = time.Now() - cleanDatabase() - log.Printf("cleaned in %s\n", time.Since(startTime)) - tx.Commit() + if _, err := os.Stat(*musicPath); os.IsNotExist(err) { + log.Fatal("please provide a valid music directory") + } + db, err := gorm.Open("sqlite3", *dbPath) + if err != nil { + log.Fatalf("error opening database: %v\n", err) + } + db.SetLogger(log.New(os.Stdout, "gorm ", 0)) + s := scanner.New( + db, + *musicPath, + ) + s.MigrateDB() + s.Start() } diff --git a/cmd/server/main.go b/cmd/server/main.go index 3bf08f6..f9186eb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,162 +1,59 @@ package main import ( - "html/template" + "flag" "log" - "net/http" - "time" + "os" - "github.com/gobuffalo/packr/v2" - "github.com/gorilla/securecookie" + "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/sqlite" - "github.com/wader/gormstore" + "github.com/peterbourgon/ff" - "github.com/sentriz/gonic/db" - "github.com/sentriz/gonic/handler" + "github.com/sentriz/gonic/server" ) -type middleware func(next http.HandlerFunc) http.HandlerFunc - -func newChain(wares ...middleware) middleware { - return func(final http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - last := final - for i := len(wares) - 1; i >= 0; i-- { - last = wares[i](last) - } - last(w, r) - } - } -} - -func extendFromBox(tmpl *template.Template, box *packr.Box, key string) *template.Template { - strT, err := box.FindString(key) - if err != nil { - log.Fatalf("error when reading template from box: %v", err) - } - if tmpl == nil { - tmpl = template.New("layout") - } else { - tmpl = template.Must(tmpl.Clone()) - } - newT, err := tmpl.Parse(strT) - if err != nil { - log.Fatalf("error when parsing template template: %v", err) - } - return newT -} - -func setSubsonicRoutes(cont handler.Controller, mux *http.ServeMux) { - withWare := newChain( - cont.WithLogging, - cont.WithCORS, - cont.WithValidSubsonicArgs, - ) - // common - mux.HandleFunc("/rest/download", withWare(cont.Stream)) - mux.HandleFunc("/rest/download.view", withWare(cont.Stream)) - mux.HandleFunc("/rest/stream", withWare(cont.Stream)) - mux.HandleFunc("/rest/stream.view", withWare(cont.Stream)) - mux.HandleFunc("/rest/getCoverArt", withWare(cont.GetCoverArt)) - mux.HandleFunc("/rest/getCoverArt.view", withWare(cont.GetCoverArt)) - mux.HandleFunc("/rest/getLicense", withWare(cont.GetLicence)) - mux.HandleFunc("/rest/getLicense.view", withWare(cont.GetLicence)) - mux.HandleFunc("/rest/ping", withWare(cont.Ping)) - mux.HandleFunc("/rest/ping.view", withWare(cont.Ping)) - mux.HandleFunc("/rest/scrobble", withWare(cont.Scrobble)) - mux.HandleFunc("/rest/scrobble.view", withWare(cont.Scrobble)) - mux.HandleFunc("/rest/getMusicFolders", withWare(cont.GetMusicFolders)) - mux.HandleFunc("/rest/getMusicFolders.view", withWare(cont.GetMusicFolders)) - // browse by tag - mux.HandleFunc("/rest/getAlbum", withWare(cont.GetAlbum)) - mux.HandleFunc("/rest/getAlbum.view", withWare(cont.GetAlbum)) - mux.HandleFunc("/rest/getAlbumList2", withWare(cont.GetAlbumListTwo)) - mux.HandleFunc("/rest/getAlbumList2.view", withWare(cont.GetAlbumListTwo)) - mux.HandleFunc("/rest/getArtist", withWare(cont.GetArtist)) - mux.HandleFunc("/rest/getArtist.view", withWare(cont.GetArtist)) - mux.HandleFunc("/rest/getArtists", withWare(cont.GetArtists)) - mux.HandleFunc("/rest/getArtists.view", withWare(cont.GetArtists)) - // browse by folder - mux.HandleFunc("/rest/getIndexes", withWare(cont.GetIndexes)) - mux.HandleFunc("/rest/getIndexes.view", withWare(cont.GetIndexes)) - mux.HandleFunc("/rest/getMusicDirectory", withWare(cont.GetMusicDirectory)) - mux.HandleFunc("/rest/getMusicDirectory.view", withWare(cont.GetMusicDirectory)) - mux.HandleFunc("/rest/getAlbumList", withWare(cont.GetAlbumList)) - mux.HandleFunc("/rest/getAlbumList.view", withWare(cont.GetAlbumList)) -} - -func setAdminRoutes(cont handler.Controller, mux *http.ServeMux) { - sessionKey := []byte(cont.GetSetting("session_key")) - if len(sessionKey) == 0 { - sessionKey = securecookie.GenerateRandomKey(32) - cont.SetSetting("session_key", string(sessionKey)) - } - // create gormstore (and cleanup) for backend sessions - cont.SStore = gormstore.New(cont.DB, []byte(sessionKey)) - go cont.SStore.PeriodicCleanup(1*time.Hour, nil) - // using packr to bundle templates and static files - box := packr.New("templates", "../../templates") - layoutT := extendFromBox(nil, box, "layout.tmpl") - userT := extendFromBox(layoutT, box, "user.tmpl") - cont.Templates = map[string]*template.Template{ - "login": extendFromBox(layoutT, box, "pages/login.tmpl"), - "home": extendFromBox(userT, box, "pages/home.tmpl"), - "change_own_password": extendFromBox(userT, box, "pages/change_own_password.tmpl"), - "change_password": extendFromBox(userT, box, "pages/change_password.tmpl"), - "delete_user": extendFromBox(userT, box, "pages/delete_user.tmpl"), - "create_user": extendFromBox(userT, box, "pages/create_user.tmpl"), - "update_lastfm_api_key": extendFromBox(userT, box, "pages/update_lastfm_api_key.tmpl"), - } - withPublicWare := newChain( - cont.WithLogging, - cont.WithSession, - ) - withUserWare := newChain( - withPublicWare, - cont.WithUserSession, - ) - withAdminWare := newChain( - withUserWare, - cont.WithAdminSession, - ) - server := http.FileServer(packr.New("static", "../../static")) - mux.Handle("/admin/static/", http.StripPrefix("/admin/static/", server)) - mux.HandleFunc("/admin/login", withPublicWare(cont.ServeLogin)) - mux.HandleFunc("/admin/login_do", withPublicWare(cont.ServeLoginDo)) - mux.HandleFunc("/admin/logout", withUserWare(cont.ServeLogout)) - mux.HandleFunc("/admin/home", withUserWare(cont.ServeHome)) - mux.HandleFunc("/admin/change_own_password", withUserWare(cont.ServeChangeOwnPassword)) - mux.HandleFunc("/admin/change_own_password_do", withUserWare(cont.ServeChangeOwnPasswordDo)) - mux.HandleFunc("/admin/link_lastfm_do", withUserWare(cont.ServeLinkLastFMDo)) - mux.HandleFunc("/admin/unlink_lastfm_do", withUserWare(cont.ServeUnlinkLastFMDo)) - mux.HandleFunc("/admin/change_password", withAdminWare(cont.ServeChangePassword)) - mux.HandleFunc("/admin/change_password_do", withAdminWare(cont.ServeChangePasswordDo)) - mux.HandleFunc("/admin/delete_user", withAdminWare(cont.ServeDeleteUser)) - mux.HandleFunc("/admin/delete_user_do", withAdminWare(cont.ServeDeleteUserDo)) - mux.HandleFunc("/admin/create_user", withAdminWare(cont.ServeCreateUser)) - mux.HandleFunc("/admin/create_user_do", withAdminWare(cont.ServeCreateUserDo)) - mux.HandleFunc("/admin/update_lastfm_api_key", withAdminWare(cont.ServeUpdateLastFMAPIKey)) - mux.HandleFunc("/admin/update_lastfm_api_key_do", withAdminWare(cont.ServeUpdateLastFMAPIKeyDo)) -} +const ( + programName = "gonic" + programVar = "GONIC" +) func main() { - address := "0.0.0.0:6969" - mux := http.NewServeMux() - // create a new controller and pass a copy to both routes. - // they will add more fields to their copy if they need them - baseController := handler.Controller{DB: db.New()} - setSubsonicRoutes(baseController, mux) - setAdminRoutes(baseController, mux) - server := &http.Server{ - Addr: address, - Handler: mux, - ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 15 * time.Second, - } - log.Printf("starting server at `%s`\n", address) - err := server.ListenAndServe() + set := flag.NewFlagSet(programName, flag.ExitOnError) + listenAddr := set.String( + "listen-addr", "0.0.0.0:6969", + "listen address (optional)") + musicPath := set.String( + "music-path", "", + "path to music") + dbPath := set.String( + "db-path", "gonic.db", + "path to database (optional)") + _ = set.String( + "config-path", "", + "path to config (optional)") + err := ff.Parse(set, os.Args[1:], + ff.WithConfigFileFlag("config-path"), + ff.WithConfigFileParser(ff.PlainParser), + ff.WithEnvVarPrefix(programVar), + ) if err != nil { - log.Printf("when starting server: %v\n", err) + log.Fatalf("error parsing args: %v\n", err) + } + if _, err := os.Stat(*musicPath); os.IsNotExist(err) { + log.Fatal("please provide a valid music directory") + } + db, err := gorm.Open("sqlite3", *dbPath) + if err != nil { + log.Fatalf("error opening database: %v\n", err) + } + s := server.New( + db, + *musicPath, + *listenAddr, + ) + log.Printf("starting server at %s", *listenAddr) + err = s.ListenAndServe() + if err != nil { + log.Fatalf("error starting server: %v\n", err) } } diff --git a/db/db.go b/db/db.go deleted file mode 100644 index 3c20cb5..0000000 --- a/db/db.go +++ /dev/null @@ -1,22 +0,0 @@ -package db - -import ( - "log" - "runtime" - - "github.com/jinzhu/gorm" -) - -var ( - // cFile is the path to this go file - _, cFile, _, _ = runtime.Caller(0) -) - -// New creates a new GORM connection to the database -func New() *gorm.DB { - db, err := gorm.Open("sqlite3", "gonic.db") - if err != nil { - log.Printf("when opening database: %v\n", err) - } - return db -} diff --git a/go.mod b/go.mod index 702a5cb..7b21aa2 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,8 @@ require ( github.com/karrick/godirwalk v1.8.0 github.com/lib/pq v1.0.0 // indirect github.com/mattn/go-sqlite3 v1.10.0 // indirect + github.com/peterbourgon/ff v1.2.0 + github.com/pkg/errors v0.8.1 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be github.com/wader/gormstore v0.0.0-20190302154359-acb787ba3755 golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd // indirect diff --git a/go.sum b/go.sum index eef7279..aa34f0d 100644 --- a/go.sum +++ b/go.sum @@ -133,6 +133,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/openzipkin/zipkin-go v0.1.3/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/peterbourgon/ff v1.2.0 h1:wGn2NwdHk8MTlRQpnXnO91UKegxt5DvlwR/bTK/L2hc= +github.com/peterbourgon/ff v1.2.0/go.mod h1:ljiF7yxtUvZaxUDyUqQa0+uiEOgwVboj+Q2S2+0nq40= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/handler/controller.go b/handler/controller.go deleted file mode 100644 index 9e2ab3a..0000000 --- a/handler/controller.go +++ /dev/null @@ -1,35 +0,0 @@ -package handler - -import ( - "html/template" - - "github.com/jinzhu/gorm" - "github.com/wader/gormstore" - - "github.com/sentriz/gonic/db" -) - -type Controller struct { - DB *gorm.DB // common - SStore *gormstore.Store // admin - Templates map[string]*template.Template // admin -} - -func (c *Controller) GetSetting(key string) string { - var setting db.Setting - c.DB.Where("key = ?", key).First(&setting) - return setting.Value -} - -func (c *Controller) SetSetting(key, value string) { - c.DB. - Where(db.Setting{Key: key}). - Assign(db.Setting{Value: value}). - FirstOrCreate(&db.Setting{}) -} - -func (c *Controller) GetUserFromName(name string) *db.User { - var user db.User - c.DB.Where("name = ?", name).First(&user) - return &user -} diff --git a/handler/handler_const.go b/handler/handler_const.go deleted file mode 100644 index 95e8aec..0000000 --- a/handler/handler_const.go +++ /dev/null @@ -1,8 +0,0 @@ -package handler - -type contextKey int - -const ( - contextUserKey contextKey = iota - contextSessionKey -) diff --git a/lastfm/lastfm.go b/lastfm/lastfm.go index 3ecc4c5..ed269f4 100644 --- a/lastfm/lastfm.go +++ b/lastfm/lastfm.go @@ -11,7 +11,9 @@ import ( "strconv" "time" - "github.com/sentriz/gonic/db" + "github.com/pkg/errors" + + "github.com/sentriz/gonic/model" ) var ( @@ -43,14 +45,14 @@ func makeRequest(method string, params url.Values) (*LastFM, error) { req.URL.RawQuery = params.Encode() resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("get: %v", err) + return nil, errors.Wrap(err, "get") } defer resp.Body.Close() decoder := xml.NewDecoder(resp.Body) var lastfm LastFM err = decoder.Decode(&lastfm) if err != nil { - return nil, fmt.Errorf("decoding: %v", err) + return nil, errors.Wrap(err, "decoding") } if lastfm.Error != nil { return nil, fmt.Errorf("parsing: %v", lastfm.Error.Value) @@ -72,7 +74,7 @@ func GetSession(apiKey, secret, token string) (string, error) { } func Scrobble(apiKey, secret, session string, - track *db.Track, stamp int, submission bool) error { + track *model.Track, stamp int, submission bool) error { params := url.Values{} if submission { params.Add("method", "track.Scrobble") diff --git a/db/base.go b/model/base.go similarity index 90% rename from db/base.go rename to model/base.go index 797881b..ee61124 100644 --- a/db/base.go +++ b/model/base.go @@ -1,4 +1,4 @@ -package db +package model import ( "time" diff --git a/db/model.go b/model/model.go similarity index 99% rename from db/model.go rename to model/model.go index fb00180..ddb3eeb 100644 --- a/db/model.go +++ b/model/model.go @@ -1,4 +1,4 @@ -package db +package model import "time" diff --git a/scanner/dir_stack.go b/scanner/dir_stack.go new file mode 100644 index 0000000..4951704 --- /dev/null +++ b/scanner/dir_stack.go @@ -0,0 +1,37 @@ +package scanner + +import ( + "github.com/sentriz/gonic/model" +) + +type dirStack []*model.Folder + +func (s *dirStack) Push(v *model.Folder) { + *s = append(*s, v) +} + +func (s *dirStack) Pop() *model.Folder { + l := len(*s) + if l == 0 { + return nil + } + r := (*s)[l-1] + *s = (*s)[:l-1] + return r +} + +func (s *dirStack) Peek() *model.Folder { + l := len(*s) + if l == 0 { + return nil + } + return (*s)[l-1] +} + +func (s *dirStack) PeekID() int { + l := len(*s) + if l == 0 { + return 0 + } + return (*s)[l-1].ID +} diff --git a/scanner/scanner.go b/scanner/scanner.go new file mode 100644 index 0000000..5a66a8b --- /dev/null +++ b/scanner/scanner.go @@ -0,0 +1,303 @@ +package scanner + +// this scanner tries to scan with a single unsorted walk of the music +// directory - which means you can come across the cover of an album/folder +// before the tracks (and therefore the album) which is an issue because +// when inserting into the album table, we need a reference to the cover. +// to solve this we're using godirwalk's PostChildrenCallback and some globals +// +// Album -> needs a CoverID +// -> needs a FolderID (American Football) +// Folder -> needs a CoverID +// -> needs a ParentID +// Track -> needs an AlbumID +// -> needs a FolderID + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "path" + "sync/atomic" + "time" + + "github.com/jinzhu/gorm" + "github.com/karrick/godirwalk" + "github.com/pkg/errors" + + "github.com/sentriz/gonic/model" +) + +var ( + IsScanning int32 +) + +type Scanner struct { + db *gorm.DB + tx *gorm.DB + musicPath string + // seenPaths is used to keep every path we've seen so that + // we can remove old tracks, folders, and covers by path when we + // are in the cleanDatabase stage + seenPaths map[string]bool + // currentDirStack is used for inserting to the folders (subsonic browse + // by folder) which helps us work out a folder's parent + currentDirStack dirStack + // currentCover because we find a cover anywhere among the tracks during the + // walk and need a reference to it when we update folder and album records + // when we exit a folder + currentCover *model.Cover + // currentAlbum because we update this record when we exit a folder with + // our new reference to it's cover + currentAlbum *model.Album +} + +func New(db *gorm.DB, musicPath string) *Scanner { + return &Scanner{ + db: db, + musicPath: musicPath, + seenPaths: make(map[string]bool), + currentDirStack: make(dirStack, 0), + currentCover: &model.Cover{}, + currentAlbum: &model.Album{}, + } +} + +func (s *Scanner) updateAlbum(fullPath string, album *model.Album) { + if s.currentAlbum.ID != 0 { + return + } + directory, _ := path.Split(fullPath) + // update album table (the currentAlbum record will be updated when + // we exit this folder) + err := s.tx.Where("path = ?", directory).First(s.currentAlbum).Error + if !gorm.IsRecordNotFoundError(err) { + // we found the record + // TODO: think about mod time here + return + } + s.currentAlbum = &model.Album{ + Path: directory, + Title: album.Title, + AlbumArtistID: album.AlbumArtistID, + Year: album.Year, + } + s.tx.Save(s.currentAlbum) +} + +func (s *Scanner) handleCover(fullPath string, stat os.FileInfo) error { + modTime := stat.ModTime() + err := s.tx.Where("path = ?", fullPath).First(s.currentCover).Error + if !gorm.IsRecordNotFoundError(err) && + modTime.Before(s.currentCover.UpdatedAt) { + // we found the record but it hasn't changed + return nil + } + image, err := ioutil.ReadFile(fullPath) + if err != nil { + return fmt.Errorf("when reading cover: %v", err) + } + s.currentCover = &model.Cover{ + Path: fullPath, + Image: image, + NewlyInserted: true, + } + s.tx.Save(s.currentCover) + return nil +} + +func (s *Scanner) handleFolder(fullPath string, stat os.FileInfo) error { + // update folder table for browsing by folder + folder := &model.Folder{} + defer s.currentDirStack.Push(folder) + modTime := stat.ModTime() + err := s.tx.Where("path = ?", fullPath).First(folder).Error + if !gorm.IsRecordNotFoundError(err) && + modTime.Before(folder.UpdatedAt) { + // we found the record but it hasn't changed + return nil + } + _, folderName := path.Split(fullPath) + folder.Path = fullPath + folder.ParentID = s.currentDirStack.PeekID() + folder.Name = folderName + s.tx.Save(folder) + return nil +} + +func (s *Scanner) handleFolderCompletion(fullPath string, info *godirwalk.Dirent) error { + currentDir := s.currentDirStack.Peek() + defer s.currentDirStack.Pop() + var dirShouldSave bool + if s.currentAlbum.ID != 0 { + s.currentAlbum.CoverID = s.currentCover.ID + s.tx.Save(s.currentAlbum) + currentDir.HasTracks = true + dirShouldSave = true + } + if s.currentCover.NewlyInserted { + currentDir.CoverID = s.currentCover.ID + dirShouldSave = true + } + if dirShouldSave { + s.tx.Save(currentDir) + } + s.currentCover = &model.Cover{} + s.currentAlbum = &model.Album{} + log.Printf("processed folder `%s`\n", fullPath) + return nil +} + +func (s *Scanner) handleTrack(fullPath string, stat os.FileInfo, mime, exten string) error { + // + // set track basics + track := &model.Track{} + modTime := stat.ModTime() + err := s.tx.Where("path = ?", fullPath).First(track).Error + if !gorm.IsRecordNotFoundError(err) && + modTime.Before(track.UpdatedAt) { + // we found the record but it hasn't changed + return nil + } + tags, err := readTags(fullPath) + if err != nil { + return fmt.Errorf("when reading tags: %v", err) + } + trackNumber, totalTracks := tags.Track() + discNumber, totalDiscs := tags.Disc() + track.Path = fullPath + track.Title = tags.Title() + track.Artist = tags.Artist() + track.DiscNumber = discNumber + track.TotalDiscs = totalDiscs + track.TotalTracks = totalTracks + track.TrackNumber = trackNumber + track.Year = tags.Year() + track.Suffix = exten + track.ContentType = mime + track.Size = int(stat.Size()) + track.FolderID = s.currentDirStack.PeekID() + // + // set album artist basics + albumArtist := &model.AlbumArtist{} + err = s.tx.Where("name = ?", tags.AlbumArtist()). + First(albumArtist). + Error + if gorm.IsRecordNotFoundError(err) { + albumArtist.Name = tags.AlbumArtist() + s.tx.Save(albumArtist) + } + track.AlbumArtistID = albumArtist.ID + // + // set temporary album's basics - will be updated with + // cover after the tracks inserted when we exit the folder + s.updateAlbum(fullPath, &model.Album{ + AlbumArtistID: albumArtist.ID, + Title: tags.Album(), + Year: tags.Year(), + }) + // + // update the track with our new album and finally save + track.AlbumID = s.currentAlbum.ID + s.tx.Save(track) + return nil +} + +func (s *Scanner) handleItem(fullPath string, info *godirwalk.Dirent) error { + s.seenPaths[fullPath] = true + stat, err := os.Stat(fullPath) + if err != nil { + return fmt.Errorf("error stating: %v", err) + } + if info.IsDir() { + return s.handleFolder(fullPath, stat) + } + if isCover(fullPath) { + return s.handleCover(fullPath, stat) + } + if mime, exten, ok := isAudio(fullPath); ok { + return s.handleTrack(fullPath, stat, mime, exten) + } + return nil +} + +func (s *Scanner) MigrateDB() error { + defer logElapsed(time.Now(), "migrating database") + s.tx = s.db.Begin() + defer s.tx.Commit() + s.tx.AutoMigrate( + &model.Album{}, + &model.AlbumArtist{}, + &model.Track{}, + &model.Cover{}, + &model.User{}, + &model.Setting{}, + &model.Play{}, + &model.Folder{}, + ) + // set starting value for `albums` table's + // auto increment + s.tx.Exec(` + INSERT INTO sqlite_sequence(name, seq) + SELECT 'albums', 500000 + WHERE NOT EXISTS (SELECT * + FROM sqlite_sequence); + `) + // create the first user if there is none + s.tx.FirstOrCreate(&model.User{}, model.User{ + Name: "admin", + Password: "admin", + IsAdmin: true, + }) + return nil +} + +func (s *Scanner) Start() error { + if atomic.LoadInt32(&IsScanning) == 1 { + return errors.New("already scanning") + } + atomic.StoreInt32(&IsScanning, 1) + defer atomic.StoreInt32(&IsScanning, 0) + defer logElapsed(time.Now(), "scanning") + s.tx = s.db.Begin() + defer s.tx.Commit() + // + // start scan logic + err := godirwalk.Walk(s.musicPath, &godirwalk.Options{ + Callback: s.handleItem, + PostChildrenCallback: s.handleFolderCompletion, + Unsorted: true, + }) + if err != nil { + return errors.Wrap(err, "walking filesystem") + } + // + // start cleaning logic + log.Println("cleaning database") + var tracks []*model.Track + s.tx.Select("id, path").Find(&tracks) + for _, track := range tracks { + _, ok := s.seenPaths[track.Path] + if ok { + continue + } + s.tx.Delete(&track) + log.Println("removed", track.Path) + } + // delete albums without tracks + s.tx.Exec(` + DELETE FROM albums + WHERE (SELECT count(id) + FROM tracks + WHERE album_id = albums.id) = 0; + `) + // delete artists without tracks + s.tx.Exec(` + DELETE FROM album_artists + WHERE (SELECT count(id) + FROM albums + WHERE album_artist_id = album_artists.id) = 0; + `) + return nil +} diff --git a/scanner/utilities.go b/scanner/utilities.go new file mode 100644 index 0000000..e892c87 --- /dev/null +++ b/scanner/utilities.go @@ -0,0 +1,69 @@ +package scanner + +import ( + "fmt" + "log" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/dhowden/tag" +) + +var audioExtensions = map[string]string{ + "mp3": "audio/mpeg", + "flac": "audio/x-flac", + "aac": "audio/x-aac", + "m4a": "audio/m4a", + "ogg": "audio/ogg", +} + +func isAudio(fullPath string) (string, string, bool) { + exten := filepath.Ext(fullPath)[1:] + mine, ok := audioExtensions[exten] + if !ok { + return "", "", false + } + return mine, exten, true +} + +var coverFilenames = map[string]bool{ + "cover.png": true, + "cover.jpg": true, + "cover.jpeg": true, + "folder.png": true, + "folder.jpg": true, + "folder.jpeg": true, + "album.png": true, + "album.jpg": true, + "album.jpeg": true, + "front.png": true, + "front.jpg": true, + "front.jpeg": true, +} + +func isCover(fullPath string) bool { + _, filename := path.Split(fullPath) + _, ok := coverFilenames[strings.ToLower(filename)] + return ok +} + +func readTags(fullPath string) (tag.Metadata, error) { + trackData, err := os.Open(fullPath) + if err != nil { + return nil, fmt.Errorf("when tags from disk: %v", err) + } + defer trackData.Close() + tags, err := tag.ReadFrom(trackData) + if err != nil { + return nil, err + } + return tags, nil +} + +func logElapsed(start time.Time, name string) { + elapsed := time.Since(start) + log.Printf("finished %s in %s\n", name, elapsed) +} diff --git a/server/handler/handler.go b/server/handler/handler.go new file mode 100644 index 0000000..d58e7cf --- /dev/null +++ b/server/handler/handler.go @@ -0,0 +1,43 @@ +package handler + +import ( + "html/template" + + "github.com/jinzhu/gorm" + "github.com/wader/gormstore" + + "github.com/sentriz/gonic/model" +) + +type contextKey int + +const ( + contextUserKey contextKey = iota + contextSessionKey +) + +type Controller struct { + DB *gorm.DB + SessDB *gormstore.Store + Templates map[string]*template.Template + MusicPath string +} + +func (c *Controller) GetSetting(key string) string { + var setting model.Setting + c.DB.Where("key = ?", key).First(&setting) + return setting.Value +} + +func (c *Controller) SetSetting(key, value string) { + c.DB. + Where(model.Setting{Key: key}). + Assign(model.Setting{Value: value}). + FirstOrCreate(&model.Setting{}) +} + +func (c *Controller) GetUserFromName(name string) *model.User { + var user model.User + c.DB.Where("name = ?", name).First(&user) + return &user +} diff --git a/handler/handler_admin.go b/server/handler/handler_admin.go similarity index 96% rename from handler/handler_admin.go rename to server/handler/handler_admin.go index 9198cdf..92236cc 100644 --- a/handler/handler_admin.go +++ b/server/handler/handler_admin.go @@ -7,7 +7,7 @@ import ( "github.com/gorilla/sessions" "github.com/jinzhu/gorm" - "github.com/sentriz/gonic/db" + "github.com/sentriz/gonic/model" "github.com/sentriz/gonic/lastfm" ) @@ -84,7 +84,7 @@ func (c *Controller) ServeChangeOwnPasswordDo(w http.ResponseWriter, r *http.Req http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return } - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) user.Password = passwordOne c.DB.Save(user) http.Redirect(w, r, "/admin/home", http.StatusSeeOther) @@ -108,14 +108,14 @@ func (c *Controller) ServeLinkLastFMDo(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return } - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) user.LastFMSession = sessionKey c.DB.Save(&user) http.Redirect(w, r, "/admin/home", http.StatusSeeOther) } func (c *Controller) ServeUnlinkLastFMDo(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) user.LastFMSession = "" c.DB.Save(&user) http.Redirect(w, r, "/admin/home", http.StatusSeeOther) @@ -127,7 +127,7 @@ func (c *Controller) ServeChangePassword(w http.ResponseWriter, r *http.Request) http.Error(w, "please provide a username", 400) return } - var user db.User + var user model.User err := c.DB.Where("name = ?", username).First(&user).Error if gorm.IsRecordNotFoundError(err) { http.Error(w, "couldn't find a user with that name", 400) @@ -141,7 +141,7 @@ func (c *Controller) ServeChangePassword(w http.ResponseWriter, r *http.Request) func (c *Controller) ServeChangePasswordDo(w http.ResponseWriter, r *http.Request) { session := r.Context().Value(contextSessionKey).(*sessions.Session) username := r.URL.Query().Get("user") - var user db.User + var user model.User c.DB.Where("name = ?", username).First(&user) passwordOne := r.FormValue("password_one") passwordTwo := r.FormValue("password_two") @@ -163,7 +163,7 @@ func (c *Controller) ServeDeleteUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "please provide a username", 400) return } - var user db.User + var user model.User err := c.DB.Where("name = ?", username).First(&user).Error if gorm.IsRecordNotFoundError(err) { http.Error(w, "couldn't find a user with that name", 400) @@ -176,7 +176,7 @@ func (c *Controller) ServeDeleteUser(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeDeleteUserDo(w http.ResponseWriter, r *http.Request) { username := r.URL.Query().Get("user") - var user db.User + var user model.User c.DB.Where("name = ?", username).First(&user) c.DB.Delete(&user) http.Redirect(w, r, "/admin/home", http.StatusSeeOther) @@ -205,7 +205,7 @@ func (c *Controller) ServeCreateUserDo(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return } - user := db.User{ + user := model.User{ Name: username, Password: passwordOne, } diff --git a/handler/handler_admin_utils.go b/server/handler/handler_admin_utils.go similarity index 100% rename from handler/handler_admin_utils.go rename to server/handler/handler_admin_utils.go diff --git a/handler/handler_sub_by_folder.go b/server/handler/handler_sub_by_folder.go similarity index 92% rename from handler/handler_sub_by_folder.go rename to server/handler/handler_sub_by_folder.go index fcc61dc..c0c32cb 100644 --- a/handler/handler_sub_by_folder.go +++ b/server/handler/handler_sub_by_folder.go @@ -6,14 +6,14 @@ import ( "github.com/jinzhu/gorm" - "github.com/sentriz/gonic/db" - "github.com/sentriz/gonic/subsonic" + "github.com/sentriz/gonic/model" + "github.com/sentriz/gonic/server/subsonic" ) func (c *Controller) GetIndexes(w http.ResponseWriter, r *http.Request) { // we are browsing by folder, but the subsonic docs show sub elements // for this, so we're going to return root directories as "artists" - var folders []*db.Folder + var folders []*model.Folder c.DB.Where("parent_id = ?", 1).Find(&folders) var indexMap = make(map[rune]*subsonic.Index) var indexes []*subsonic.Index @@ -48,11 +48,11 @@ func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) { return } childrenObj := []*subsonic.Child{} - var cFolder db.Folder + var cFolder model.Folder c.DB.First(&cFolder, id) // // start looking for child folders in the current dir - var folders []*db.Folder + var folders []*model.Folder c.DB. Where("parent_id = ?", id). Find(&folders) @@ -67,7 +67,7 @@ func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) { } // // start looking for child tracks in the current dir - var tracks []*db.Track + var tracks []*model.Track c.DB. Where("folder_id = ?", id). Preload("Album"). @@ -129,7 +129,7 @@ func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) { // not sure about "name" either, so lets use the folder's name q = q.Order("name") case "frequent": - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) q = q.Joins(` JOIN plays ON folders.id = plays.folder_id AND plays.user_id = ?`, @@ -140,7 +140,7 @@ func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) { case "random": q = q.Order(gorm.Expr("random()")) case "recent": - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) q = q.Joins(` JOIN plays ON folders.id = plays.folder_id AND plays.user_id = ?`, @@ -152,7 +152,7 @@ func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) { )) return } - var folders []*db.Folder + var folders []*model.Folder q. Where("folders.has_tracks = 1"). Offset(getIntParamOr(r, "offset", 0)). diff --git a/handler/handler_sub_by_tags.go b/server/handler/handler_sub_by_tags.go similarity index 93% rename from handler/handler_sub_by_tags.go rename to server/handler/handler_sub_by_tags.go index c49df46..8a5599e 100644 --- a/handler/handler_sub_by_tags.go +++ b/server/handler/handler_sub_by_tags.go @@ -6,12 +6,12 @@ import ( "github.com/jinzhu/gorm" - "github.com/sentriz/gonic/db" - "github.com/sentriz/gonic/subsonic" + "github.com/sentriz/gonic/model" + "github.com/sentriz/gonic/server/subsonic" ) func (c *Controller) GetArtists(w http.ResponseWriter, r *http.Request) { - var artists []*db.AlbumArtist + var artists []*model.AlbumArtist c.DB.Find(&artists) var indexMap = make(map[rune]*subsonic.Index) var indexes subsonic.Artists @@ -42,7 +42,7 @@ func (c *Controller) GetArtist(w http.ResponseWriter, r *http.Request) { respondError(w, r, 10, "please provide an `id` parameter") return } - var artist db.AlbumArtist + var artist model.AlbumArtist c.DB. Preload("Albums"). First(&artist, id) @@ -72,7 +72,7 @@ func (c *Controller) GetAlbum(w http.ResponseWriter, r *http.Request) { respondError(w, r, 10, "please provide an `id` parameter") return } - var album db.Album + var album model.Album c.DB. Preload("AlbumArtist"). Preload("Tracks"). @@ -132,7 +132,7 @@ func (c *Controller) GetAlbumListTwo(w http.ResponseWriter, r *http.Request) { getIntParamOr(r, "toYear", 2200)) q = q.Order("year") case "frequent": - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) q = q.Joins(` JOIN plays ON albums.id = plays.album_id AND plays.user_id = ?`, @@ -143,7 +143,7 @@ func (c *Controller) GetAlbumListTwo(w http.ResponseWriter, r *http.Request) { case "random": q = q.Order(gorm.Expr("random()")) case "recent": - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) q = q.Joins(` JOIN plays ON albums.id = plays.album_id AND plays.user_id = ?`, @@ -155,7 +155,7 @@ func (c *Controller) GetAlbumListTwo(w http.ResponseWriter, r *http.Request) { )) return } - var albums []*db.Album + var albums []*model.Album q. Offset(getIntParamOr(r, "offset", 0)). Limit(getIntParamOr(r, "size", 10)). diff --git a/handler/handler_sub_common.go b/server/handler/handler_sub_common.go similarity index 79% rename from handler/handler_sub_common.go rename to server/handler/handler_sub_common.go index 13b9360..c8a93d6 100644 --- a/handler/handler_sub_common.go +++ b/server/handler/handler_sub_common.go @@ -4,14 +4,16 @@ import ( "fmt" "net/http" "os" + "sync/atomic" "time" "unicode" "github.com/rainycape/unidecode" - "github.com/sentriz/gonic/db" "github.com/sentriz/gonic/lastfm" - "github.com/sentriz/gonic/subsonic" + "github.com/sentriz/gonic/model" + "github.com/sentriz/gonic/scanner" + "github.com/sentriz/gonic/server/subsonic" ) func indexOf(s string) rune { @@ -29,7 +31,7 @@ func (c *Controller) Stream(w http.ResponseWriter, r *http.Request) { respondError(w, r, 10, "please provide an `id` parameter") return } - var track db.Track + var track model.Track c.DB. Preload("Album"). Preload("Folder"). @@ -47,8 +49,8 @@ func (c *Controller) Stream(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, track.Path, stat.ModTime(), file) // // after we've served the file, mark the album as played - user := r.Context().Value(contextUserKey).(*db.User) - play := db.Play{ + user := r.Context().Value(contextUserKey).(*model.User) + play := model.Play{ AlbumID: track.Album.ID, FolderID: track.Folder.ID, UserID: user.ID, @@ -65,7 +67,7 @@ func (c *Controller) GetCoverArt(w http.ResponseWriter, r *http.Request) { respondError(w, r, 10, "please provide an `id` parameter") return } - var cover db.Cover + var cover model.Cover c.DB.First(&cover, id) w.Write(cover.Image) } @@ -90,13 +92,13 @@ func (c *Controller) Scrobble(w http.ResponseWriter, r *http.Request) { return } // fetch user to get lastfm session - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) if user.LastFMSession == "" { respondError(w, r, 0, fmt.Sprintf("no last.fm session for this user: %v", err)) return } // fetch track for getting info to send to last.fm function - var track db.Track + var track model.Track c.DB. Preload("Album"). Preload("AlbumArtist"). @@ -133,6 +135,23 @@ func (c *Controller) GetMusicFolders(w http.ResponseWriter, r *http.Request) { respond(w, r, sub) } +func (c *Controller) StartScan(w http.ResponseWriter, r *http.Request) { + scanC := scanner.New(c.DB, c.MusicPath) + go scanC.Start() + c.GetScanStatus(w, r) +} + +func (c *Controller) GetScanStatus(w http.ResponseWriter, r *http.Request) { + var trackCount int + c.DB.Model(&model.Track{}).Count(&trackCount) + sub := subsonic.NewResponse() + sub.ScanStatus = &subsonic.ScanStatus{ + Scanning: atomic.LoadInt32(&scanner.IsScanning) == 1, + Count: trackCount, + } + respond(w, r, sub) +} + func (c *Controller) NotFound(w http.ResponseWriter, r *http.Request) { respondError(w, r, 0, "unknown route") } diff --git a/handler/middleware_admin.go b/server/handler/middleware_admin.go similarity index 92% rename from handler/middleware_admin.go rename to server/handler/middleware_admin.go index 007a762..4b1fbcd 100644 --- a/handler/middleware_admin.go +++ b/server/handler/middleware_admin.go @@ -6,12 +6,12 @@ import ( "github.com/gorilla/sessions" - "github.com/sentriz/gonic/db" + "github.com/sentriz/gonic/model" ) func (c *Controller) WithSession(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - session, _ := c.SStore.Get(r, "gonic") + session, _ := c.SessDB.Get(r, "gonic") withSession := context.WithValue(r.Context(), contextSessionKey, session) next.ServeHTTP(w, r.WithContext(withSession)) } @@ -47,7 +47,7 @@ func (c *Controller) WithAdminSession(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // session and user exist at this point session := r.Context().Value(contextSessionKey).(*sessions.Session) - user := r.Context().Value(contextUserKey).(*db.User) + user := r.Context().Value(contextUserKey).(*model.User) if !user.IsAdmin { session.AddFlash("you are not an admin") session.Save(r, w) diff --git a/handler/middleware_common.go b/server/handler/middleware_common.go similarity index 100% rename from handler/middleware_common.go rename to server/handler/middleware_common.go diff --git a/handler/middleware_sub.go b/server/handler/middleware_sub.go similarity index 100% rename from handler/middleware_sub.go rename to server/handler/middleware_sub.go diff --git a/handler/parse.go b/server/handler/parse.go similarity index 100% rename from handler/parse.go rename to server/handler/parse.go diff --git a/handler/respond_admin.go b/server/handler/respond_admin.go similarity index 79% rename from handler/respond_admin.go rename to server/handler/respond_admin.go index dc6dff1..f3bbd96 100644 --- a/handler/respond_admin.go +++ b/server/handler/respond_admin.go @@ -7,14 +7,14 @@ import ( "github.com/gorilla/sessions" - "github.com/sentriz/gonic/db" + "github.com/sentriz/gonic/model" ) type templateData struct { Flashes []interface{} - User *db.User - SelectedUser *db.User - AllUsers []*db.User + User *model.User + SelectedUser *model.User + AllUsers []*model.User ArtistCount int AlbumCount int TrackCount int @@ -31,7 +31,7 @@ func renderTemplate(w http.ResponseWriter, r *http.Request, } data.Flashes = session.Flashes() session.Save(r, w) - user, ok := r.Context().Value(contextUserKey).(*db.User) + user, ok := r.Context().Value(contextUserKey).(*model.User) if ok { data.User = user } diff --git a/handler/respond_sub.go b/server/handler/respond_sub.go similarity index 96% rename from handler/respond_sub.go rename to server/handler/respond_sub.go index 25332fa..04c793e 100644 --- a/handler/respond_sub.go +++ b/server/handler/respond_sub.go @@ -6,7 +6,7 @@ import ( "log" "net/http" - "github.com/sentriz/gonic/subsonic" + "github.com/sentriz/gonic/server/subsonic" ) func respondRaw(w http.ResponseWriter, r *http.Request, diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..0fe3726 --- /dev/null +++ b/server/server.go @@ -0,0 +1,54 @@ +package server + +import ( + "net/http" + "time" + + "github.com/jinzhu/gorm" + _ "github.com/jinzhu/gorm/dialects/sqlite" + + "github.com/sentriz/gonic/server/handler" +) + +type middleware func(next http.HandlerFunc) http.HandlerFunc + +func newChain(wares ...middleware) middleware { + return func(final http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + last := final + for i := len(wares) - 1; i >= 0; i-- { + last = wares[i](last) + } + last(w, r) + } + } +} + +type Server struct { + mux *http.ServeMux + *handler.Controller + *http.Server +} + +func New(db *gorm.DB, musicPath string, listenAddr string) *Server { + mux := http.NewServeMux() + server := &http.Server{ + Addr: listenAddr, + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + } + controller := &handler.Controller{ + DB: db, + MusicPath: musicPath, + } + ret := &Server{ + mux: mux, + Server: server, + Controller: controller, + } + ret.setupAdmin() + ret.setupSubsonic() + return ret +} diff --git a/server/setup_admin.go b/server/setup_admin.go new file mode 100644 index 0000000..9f604f8 --- /dev/null +++ b/server/setup_admin.go @@ -0,0 +1,83 @@ +package server + +import ( + "html/template" + "log" + "net/http" + "time" + + "github.com/gobuffalo/packr/v2" + "github.com/gorilla/securecookie" + "github.com/wader/gormstore" +) + +func extendFromBox(tmpl *template.Template, box *packr.Box, key string) *template.Template { + strT, err := box.FindString(key) + if err != nil { + log.Fatalf("error when reading template from box: %v", err) + } + if tmpl == nil { + tmpl = template.New("layout") + } else { + tmpl = template.Must(tmpl.Clone()) + } + newT, err := tmpl.Parse(strT) + if err != nil { + log.Fatalf("error when parsing template template: %v", err) + } + return newT +} + +func (s *Server) setupAdmin() { + sessionKey := []byte(s.GetSetting("session_key")) + if len(sessionKey) == 0 { + sessionKey = securecookie.GenerateRandomKey(32) + s.SetSetting("session_key", string(sessionKey)) + } + // create gormstore (and cleanup) for backend sessions + s.SessDB = gormstore.New(s.DB, []byte(sessionKey)) + go s.SessDB.PeriodicCleanup(1*time.Hour, nil) + // using packr to bundle templates and static files + box := packr.New("templates", "templates") + layoutT := extendFromBox(nil, box, "layout.tmpl") + userT := extendFromBox(layoutT, box, "user.tmpl") + s.Templates = map[string]*template.Template{ + "login": extendFromBox(layoutT, box, "pages/login.tmpl"), + "home": extendFromBox(userT, box, "pages/home.tmpl"), + "change_own_password": extendFromBox(userT, box, "pages/change_own_password.tmpl"), + "change_password": extendFromBox(userT, box, "pages/change_password.tmpl"), + "delete_user": extendFromBox(userT, box, "pages/delete_user.tmpl"), + "create_user": extendFromBox(userT, box, "pages/create_user.tmpl"), + "update_lastfm_api_key": extendFromBox(userT, box, "pages/update_lastfm_api_key.tmpl"), + } + withPublicWare := newChain( + s.WithLogging, + s.WithSession, + ) + withUserWare := newChain( + withPublicWare, + s.WithUserSession, + ) + withAdminWare := newChain( + withUserWare, + s.WithAdminSession, + ) + server := http.FileServer(packr.New("static", "static")) + s.mux.Handle("/admin/static/", http.StripPrefix("/admin/static/", server)) + s.mux.HandleFunc("/admin/login", withPublicWare(s.ServeLogin)) + s.mux.HandleFunc("/admin/login_do", withPublicWare(s.ServeLoginDo)) + s.mux.HandleFunc("/admin/logout", withUserWare(s.ServeLogout)) + s.mux.HandleFunc("/admin/home", withUserWare(s.ServeHome)) + s.mux.HandleFunc("/admin/change_own_password", withUserWare(s.ServeChangeOwnPassword)) + s.mux.HandleFunc("/admin/change_own_password_do", withUserWare(s.ServeChangeOwnPasswordDo)) + s.mux.HandleFunc("/admin/link_lastfm_do", withUserWare(s.ServeLinkLastFMDo)) + s.mux.HandleFunc("/admin/unlink_lastfm_do", withUserWare(s.ServeUnlinkLastFMDo)) + s.mux.HandleFunc("/admin/change_password", withAdminWare(s.ServeChangePassword)) + s.mux.HandleFunc("/admin/change_password_do", withAdminWare(s.ServeChangePasswordDo)) + s.mux.HandleFunc("/admin/delete_user", withAdminWare(s.ServeDeleteUser)) + s.mux.HandleFunc("/admin/delete_user_do", withAdminWare(s.ServeDeleteUserDo)) + s.mux.HandleFunc("/admin/create_user", withAdminWare(s.ServeCreateUser)) + s.mux.HandleFunc("/admin/create_user_do", withAdminWare(s.ServeCreateUserDo)) + s.mux.HandleFunc("/admin/update_lastfm_api_key", withAdminWare(s.ServeUpdateLastFMAPIKey)) + s.mux.HandleFunc("/admin/update_lastfm_api_key_do", withAdminWare(s.ServeUpdateLastFMAPIKeyDo)) +} diff --git a/server/setup_subsonic.go b/server/setup_subsonic.go new file mode 100644 index 0000000..25cd386 --- /dev/null +++ b/server/setup_subsonic.go @@ -0,0 +1,44 @@ +package server + +func (s *Server) setupSubsonic() { + withWare := newChain( + s.WithLogging, + s.WithCORS, + s.WithValidSubsonicArgs, + ) + // common + s.mux.HandleFunc("/rest/download", withWare(s.Stream)) + s.mux.HandleFunc("/rest/download.view", withWare(s.Stream)) + s.mux.HandleFunc("/rest/stream", withWare(s.Stream)) + s.mux.HandleFunc("/rest/stream.view", withWare(s.Stream)) + s.mux.HandleFunc("/rest/getCoverArt", withWare(s.GetCoverArt)) + s.mux.HandleFunc("/rest/getCoverArt.view", withWare(s.GetCoverArt)) + s.mux.HandleFunc("/rest/getLicense", withWare(s.GetLicence)) + s.mux.HandleFunc("/rest/getLicense.view", withWare(s.GetLicence)) + s.mux.HandleFunc("/rest/ping", withWare(s.Ping)) + s.mux.HandleFunc("/rest/ping.view", withWare(s.Ping)) + s.mux.HandleFunc("/rest/scrobble", withWare(s.Scrobble)) + s.mux.HandleFunc("/rest/scrobble.view", withWare(s.Scrobble)) + s.mux.HandleFunc("/rest/getMusicFolders", withWare(s.GetMusicFolders)) + s.mux.HandleFunc("/rest/getMusicFolders.view", withWare(s.GetMusicFolders)) + s.mux.HandleFunc("/rest/startScan", withWare(s.StartScan)) + s.mux.HandleFunc("/rest/startScan.view", withWare(s.StartScan)) + s.mux.HandleFunc("/rest/getScanStatus", withWare(s.GetScanStatus)) + s.mux.HandleFunc("/rest/getScanStatus.view", withWare(s.GetScanStatus)) + // browse by tag + s.mux.HandleFunc("/rest/getAlbum", withWare(s.GetAlbum)) + s.mux.HandleFunc("/rest/getAlbum.view", withWare(s.GetAlbum)) + s.mux.HandleFunc("/rest/getAlbumList2", withWare(s.GetAlbumListTwo)) + s.mux.HandleFunc("/rest/getAlbumList2.view", withWare(s.GetAlbumListTwo)) + s.mux.HandleFunc("/rest/getArtist", withWare(s.GetArtist)) + s.mux.HandleFunc("/rest/getArtist.view", withWare(s.GetArtist)) + s.mux.HandleFunc("/rest/getArtists", withWare(s.GetArtists)) + s.mux.HandleFunc("/rest/getArtists.view", withWare(s.GetArtists)) + // browse by folder + s.mux.HandleFunc("/rest/getIndexes", withWare(s.GetIndexes)) + s.mux.HandleFunc("/rest/getIndexes.view", withWare(s.GetIndexes)) + s.mux.HandleFunc("/rest/getMusicDirectory", withWare(s.GetMusicDirectory)) + s.mux.HandleFunc("/rest/getMusicDirectory.view", withWare(s.GetMusicDirectory)) + s.mux.HandleFunc("/rest/getAlbumList", withWare(s.GetAlbumList)) + s.mux.HandleFunc("/rest/getAlbumList.view", withWare(s.GetAlbumList)) +} diff --git a/static/images/favicon.ico b/server/static/images/favicon.ico similarity index 100% rename from static/images/favicon.ico rename to server/static/images/favicon.ico diff --git a/static/images/gonic.png b/server/static/images/gonic.png similarity index 100% rename from static/images/gonic.png rename to server/static/images/gonic.png diff --git a/static/stylesheets/awsm.css b/server/static/stylesheets/awsm.css similarity index 100% rename from static/stylesheets/awsm.css rename to server/static/stylesheets/awsm.css diff --git a/static/stylesheets/main.css b/server/static/stylesheets/main.css similarity index 100% rename from static/stylesheets/main.css rename to server/static/stylesheets/main.css diff --git a/static/stylesheets/tacit.css b/server/static/stylesheets/tacit.css similarity index 100% rename from static/stylesheets/tacit.css rename to server/static/stylesheets/tacit.css diff --git a/subsonic/media.go b/server/subsonic/media.go similarity index 97% rename from subsonic/media.go rename to server/subsonic/media.go index 0afb93f..cf02cd1 100644 --- a/subsonic/media.go +++ b/server/subsonic/media.go @@ -116,3 +116,8 @@ type MusicFolder struct { type Licence struct { Valid bool `xml:"valid,attr,omitempty" json:"valid,omitempty"` } + +type ScanStatus struct { + Scanning bool `xml:"scanning,attr" json:"scanning"` + Count int `xml:"count,attr,omitempty" json:"count,omitempty"` +} diff --git a/subsonic/response.go b/server/subsonic/response.go similarity index 96% rename from subsonic/response.go rename to server/subsonic/response.go index 95b09ba..1b1968c 100644 --- a/subsonic/response.go +++ b/server/subsonic/response.go @@ -29,6 +29,7 @@ type Response struct { Directory *Directory `xml:"directory" json:"directory,omitempty"` RandomTracks *RandomTracks `xml:"randomSongs" json:"randomSongs,omitempty"` MusicFolders *MusicFolders `xml:"musicFolders" json:"musicFolders,omitempty"` + ScanStatus *ScanStatus `xml:"scanStatus" json:"scanStatus,omitempty"` Licence *Licence `xml:"license" json:"license,omitempty"` } diff --git a/templates/layout.tmpl b/server/templates/layout.tmpl similarity index 100% rename from templates/layout.tmpl rename to server/templates/layout.tmpl diff --git a/templates/pages/change_own_password.tmpl b/server/templates/pages/change_own_password.tmpl similarity index 100% rename from templates/pages/change_own_password.tmpl rename to server/templates/pages/change_own_password.tmpl diff --git a/templates/pages/change_password.tmpl b/server/templates/pages/change_password.tmpl similarity index 100% rename from templates/pages/change_password.tmpl rename to server/templates/pages/change_password.tmpl diff --git a/templates/pages/create_user.tmpl b/server/templates/pages/create_user.tmpl similarity index 100% rename from templates/pages/create_user.tmpl rename to server/templates/pages/create_user.tmpl diff --git a/templates/pages/delete_user.tmpl b/server/templates/pages/delete_user.tmpl similarity index 100% rename from templates/pages/delete_user.tmpl rename to server/templates/pages/delete_user.tmpl diff --git a/templates/pages/home.tmpl b/server/templates/pages/home.tmpl similarity index 100% rename from templates/pages/home.tmpl rename to server/templates/pages/home.tmpl diff --git a/templates/pages/login.tmpl b/server/templates/pages/login.tmpl similarity index 100% rename from templates/pages/login.tmpl rename to server/templates/pages/login.tmpl diff --git a/templates/pages/update_lastfm_api_key.tmpl b/server/templates/pages/update_lastfm_api_key.tmpl similarity index 100% rename from templates/pages/update_lastfm_api_key.tmpl rename to server/templates/pages/update_lastfm_api_key.tmpl diff --git a/templates/user.tmpl b/server/templates/user.tmpl similarity index 100% rename from templates/user.tmpl rename to server/templates/user.tmpl