From c9b0157333a3e13c63d077b2aa21e7a37d78ed40 Mon Sep 17 00:00:00 2001 From: sentriz Date: Tue, 30 Apr 2019 17:35:08 +0100 Subject: [PATCH] add init browse by folder --- cmd/scanner/main.go | 111 +++++++-------- cmd/server/main.go | 39 +++--- db/model.go | 11 ++ go.mod | 2 + go.sum | 2 + handler/handler_sub_by_folder.go | 39 ++++++ ...{handler_sub.go => handler_sub_by_tags.go} | 116 ---------------- handler/handler_sub_common.go | 126 ++++++++++++++++++ 8 files changed, 250 insertions(+), 196 deletions(-) create mode 100644 handler/handler_sub_by_folder.go rename handler/{handler_sub.go => handler_sub_by_tags.go} (54%) create mode 100644 handler/handler_sub_common.go diff --git a/cmd/scanner/main.go b/cmd/scanner/main.go index bc06132..f225601 100644 --- a/cmd/scanner/main.go +++ b/cmd/scanner/main.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io/ioutil" "log" "os" "path" @@ -19,43 +18,17 @@ import ( ) var ( - orm *gorm.DB - tx *gorm.DB - cLastAlbum = &lastAlbum{} - audioExtensions = map[string]string{ - "mp3": "audio/mpeg", - "flac": "audio/x-flac", - "aac": "audio/x-aac", - "m4a": "audio/m4a", - "ogg": "audio/ogg", - } - 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, - } + orm *gorm.DB + tx *gorm.DB + // seenTracks is used to keep every track we've seen so that + // we can later remove old tracks from the database seenTracks = make(map[string]bool) + // seenDirs is used for inserting to the folders table (for browsing + // by folders instead of tags) which helps us work out a folder's + // parent folder id + seenDirs = make(dirStack, 0) ) -type lastAlbum struct { - coverModTime time.Time // 1st needed for cover insertion - coverPath string // 2rd needed for cover insertion - id int // 3nd needed for cover insertion -} - -func (l *lastAlbum) isEmpty() bool { - return l.coverPath == "" -} - func isCover(filename string) bool { _, ok := coverFilenames[strings.ToLower(filename)] return ok @@ -74,37 +47,37 @@ func readTags(fullPath string) (tag.Metadata, error) { return tags, nil } -func handleFolderCompletion(fullPath string, info *godirwalk.Dirent) error { - log.Printf("++++++ processed folder `%s`\n", fullPath) - if cLastAlbum.isEmpty() { - return nil +// handleFolder is for browse by folders, while handleFile is for both +func handleFolder(fullPath string, info *godirwalk.Dirent) error { + stat, err := os.Stat(fullPath) + if err != nil { + return fmt.Errorf("when stating folder: %v", err) } - cover := db.Cover{ - Path: cLastAlbum.coverPath, + modTime := stat.ModTime() + folder := db.Folder{ + Path: fullPath, } // skip if the record exists and hasn't been modified since // the last scan - err := tx.Where(cover).First(&cover).Error + err = tx.Where(folder).First(&folder).Error if !gorm.IsRecordNotFoundError(err) && - cLastAlbum.coverModTime.Before(cover.UpdatedAt) { + modTime.Before(folder.UpdatedAt) { + // even though we don't want to update this record, + // add it to seenDirs now that we have the id + seenDirs.Push(folder.ID) return nil } - image, err := ioutil.ReadFile(cLastAlbum.coverPath) - if err != nil { - return fmt.Errorf("when reading cover: %v", err) - } - cover.Image = image - cover.AlbumID = cLastAlbum.id - tx.Save(&cover) - cLastAlbum = &lastAlbum{} + _, folderName := path.Split(fullPath) + folder.ParentID = seenDirs.Peek() + folder.Name = folderName + // save the record with new parent id, then add the new + // current id to seenDirs + tx.Save(&folder) + seenDirs.Push(folder.ID) return nil } func handleFile(fullPath string, info *godirwalk.Dirent) error { - fmt.Println("+++++", fullPath) - if info.IsDir() { - return nil - } stat, err := os.Stat(fullPath) if err != nil { return fmt.Errorf("when stating file: %v", err) @@ -112,8 +85,6 @@ func handleFile(fullPath string, info *godirwalk.Dirent) error { modTime := stat.ModTime() _, filename := path.Split(fullPath) if isCover(filename) { - cLastAlbum.coverModTime = modTime // 1st needed for cover insertion - cLastAlbum.coverPath = fullPath // 2nd needed for cover insertion return nil } longExt := filepath.Ext(filename) @@ -123,9 +94,8 @@ func handleFile(fullPath string, info *godirwalk.Dirent) error { if !ok { return nil } - // add the full path to the seen set. later used to delete - // tracks that are no longer on filesystem and still in the - // database + // add the full path to the seen set. see the comment above + // seenTracks for more seenTracks[fullPath] = true // set track basics track := db.Track{ @@ -155,6 +125,7 @@ func handleFile(fullPath string, info *godirwalk.Dirent) error { track.Suffix = extension track.ContentType = mime track.Size = int(stat.Size()) + track.FolderID = seenDirs.Peek() // set album artist { albumArtist := db.AlbumArtist{ Name: tags.AlbumArtist(), @@ -177,14 +148,25 @@ func handleFile(fullPath string, info *godirwalk.Dirent) error { tx.Save(&album) } track.AlbumID = album.ID - // set the _3rd_ variable for cover insertion. - // it will be used by the `handleFolderCompletion` function - cLastAlbum.id = album.ID // save track tx.Save(&track) return nil } +func handleFolderCompletion(fullPath string, info *godirwalk.Dirent) error { + seenDirs.Pop() + log.Printf("processed folder `%s`\n", fullPath) + return nil +} + +func handleItem(fullPath string, info *godirwalk.Dirent) error { + // TODO: stat here instead of in each handler + if info.IsDir() { + return handleFolder(fullPath, info) + } + return handleFile(fullPath, info) +} + func createDatabase() { tx.AutoMigrate( &db.Album{}, @@ -194,6 +176,7 @@ func createDatabase() { &db.User{}, &db.Setting{}, &db.Play{}, + &db.Folder{}, ) // set starting value for `albums` table's // auto increment @@ -249,7 +232,7 @@ func main() { createDatabase() startTime := time.Now() err := godirwalk.Walk(os.Args[1], &godirwalk.Options{ - Callback: handleFile, + Callback: handleItem, PostChildrenCallback: handleFolderCompletion, Unsorted: true, }) diff --git a/cmd/server/main.go b/cmd/server/main.go index cc74b90..1bade1b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -52,28 +52,35 @@ func setSubsonicRoutes(cont handler.Controller, mux *http.ServeMux) { cont.WithCORS, cont.WithValidSubsonicArgs, ) - mux.HandleFunc("/rest/ping", withWare(cont.Ping)) - mux.HandleFunc("/rest/ping.view", withWare(cont.Ping)) - mux.HandleFunc("/rest/stream", withWare(cont.Stream)) - mux.HandleFunc("/rest/stream.view", withWare(cont.Stream)) + // common mux.HandleFunc("/rest/download", withWare(cont.Stream)) mux.HandleFunc("/rest/download.view", withWare(cont.Stream)) - mux.HandleFunc("/rest/scrobble", withWare(cont.Scrobble)) - mux.HandleFunc("/rest/scrobble.view", withWare(cont.Scrobble)) + 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/getArtists", withWare(cont.GetArtists)) - mux.HandleFunc("/rest/getArtists.view", withWare(cont.GetArtists)) - mux.HandleFunc("/rest/getArtist", withWare(cont.GetArtist)) - mux.HandleFunc("/rest/getArtist.view", withWare(cont.GetArtist)) - mux.HandleFunc("/rest/getAlbum", withWare(cont.GetAlbum)) - mux.HandleFunc("/rest/getAlbum.view", withWare(cont.GetAlbum)) - mux.HandleFunc("/rest/getMusicFolders", withWare(cont.GetMusicFolders)) - mux.HandleFunc("/rest/getMusicFolders.view", withWare(cont.GetMusicFolders)) - mux.HandleFunc("/rest/getAlbumList2", withWare(cont.GetAlbumList)) - mux.HandleFunc("/rest/getAlbumList2.view", withWare(cont.GetAlbumList)) 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.GetAlbumList)) + mux.HandleFunc("/rest/getAlbumList2.view", withWare(cont.GetAlbumList)) + 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)) } func setAdminRoutes(cont handler.Controller, mux *http.ServeMux) { diff --git a/db/model.go b/db/model.go index 12d1822..e32209f 100644 --- a/db/model.go +++ b/db/model.go @@ -41,6 +41,7 @@ type Track struct { Suffix string ContentType string Size int + FolderID int Path string `gorm:"not null;unique_index"` } @@ -79,3 +80,13 @@ type Play struct { TrackID int Time time.Time } + +// Folder represents the settings table +type Folder struct { + IDBase + CrudBase + Name string + Path string `gorm:"not null;unique_index"` + Parent *Folder `gorm:"foreignkey:ParentID"` + ParentID int +} diff --git a/go.mod b/go.mod index bf19859..ebdcdc9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ require ( cloud.google.com/go v0.37.1 // indirect github.com/cosiner/argv v0.0.1 // indirect github.com/cpuguy83/go-md2man v1.0.10 // indirect + github.com/davecgh/go-spew v1.1.1 github.com/davidrjenni/reftools v0.0.0-20190411195930-981bbac422f8 // indirect github.com/denisenkom/go-mssqldb v0.0.0-20190315220205-a8ed825ac853 // indirect github.com/dhowden/tag v0.0.0-20181104225729-a9f04c2798ca @@ -33,6 +34,7 @@ require ( github.com/mozillazg/go-unidecode v0.1.1 github.com/peterh/liner v1.1.0 // indirect github.com/pkg/profile v1.3.0 // indirect + github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be github.com/rogpeppe/godef v1.1.1 // indirect github.com/russross/blackfriday v2.0.0+incompatible // indirect github.com/stretchr/objx v0.2.0 // indirect diff --git a/go.sum b/go.sum index ed7eb8c..6a3f3b5 100644 --- a/go.sum +++ b/go.sum @@ -271,6 +271,8 @@ github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk= diff --git a/handler/handler_sub_by_folder.go b/handler/handler_sub_by_folder.go new file mode 100644 index 0000000..04d2a4b --- /dev/null +++ b/handler/handler_sub_by_folder.go @@ -0,0 +1,39 @@ +package handler + +import ( + "net/http" + + "github.com/sentriz/gonic/db" + "github.com/sentriz/gonic/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 + c.DB.Where("parent_id = ?", 1).Find(&folders) + var indexMap = make(map[rune]*subsonic.Index) + var indexes []*subsonic.Index + for _, folder := range folders { + i := indexOf(folder.Name) + index, ok := indexMap[i] + if !ok { + index = &subsonic.Index{ + Name: string(i), + Artists: []*subsonic.Artist{}, + } + indexMap[i] = index + indexes = append(indexes, index) + } + index.Artists = append(index.Artists, &subsonic.Artist{ + ID: folder.ID, + Name: folder.Name, + }) + } + sub := subsonic.NewResponse() + sub.Artists = indexes + respond(w, r, sub) +} + +func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) { +} diff --git a/handler/handler_sub.go b/handler/handler_sub_by_tags.go similarity index 54% rename from handler/handler_sub.go rename to handler/handler_sub_by_tags.go index 4b7b78a..ad1dc4b 100644 --- a/handler/handler_sub.go +++ b/handler/handler_sub_by_tags.go @@ -3,15 +3,10 @@ package handler import ( "fmt" "net/http" - "os" - "time" - "unicode" "github.com/jinzhu/gorm" - "github.com/mozillazg/go-unidecode" "github.com/sentriz/gonic/db" - "github.com/sentriz/gonic/lastfm" "github.com/sentriz/gonic/subsonic" ) @@ -21,31 +16,6 @@ var orderExpr = map[string]interface{}{ "alphabeticalByName": "title", } -func indexOf(s string) rune { - first := string(s[0]) - c := rune(unidecode.Unidecode(first)[0]) - if !unicode.IsLetter(c) { - return '#' - } - return c -} - -func (c *Controller) Ping(w http.ResponseWriter, r *http.Request) { - sub := subsonic.NewResponse() - respond(w, r, sub) -} - -func (c *Controller) GetCoverArt(w http.ResponseWriter, r *http.Request) { - id, err := getIntParam(r, "id") - if err != nil { - respondError(w, r, 10, "please provide an `id` parameter") - return - } - var cover db.Cover - c.DB.First(&cover, id) - w.Write(cover.Image) -} - func (c *Controller) GetArtists(w http.ResponseWriter, r *http.Request) { var artists []*db.AlbumArtist c.DB.Find(&artists) @@ -140,14 +110,6 @@ func (c *Controller) GetAlbum(w http.ResponseWriter, r *http.Request) { respond(w, r, sub) } -func (c *Controller) GetMusicFolders(w http.ResponseWriter, r *http.Request) { - sub := subsonic.NewResponse() - sub.MusicFolders = []*subsonic.MusicFolder{ - {ID: 0, Name: "music"}, - } - respond(w, r, sub) -} - func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) { listType := getStrParam(r, "type") if listType == "" { @@ -181,81 +143,3 @@ func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) { } respond(w, r, sub) } - -func (c *Controller) Stream(w http.ResponseWriter, r *http.Request) { - id, err := getIntParam(r, "id") - if err != nil { - respondError(w, r, 10, "please provide an `id` parameter") - return - } - var track db.Track - c.DB.First(&track, id) - if track.Path == "" { - respondError(w, r, 70, fmt.Sprintf("media with id `%d` was not found", id)) - return - } - file, err := os.Open(track.Path) - if err != nil { - respondError(w, r, 0, fmt.Sprintf("error while streaming media: %v", err)) - return - } - stat, _ := file.Stat() - http.ServeContent(w, r, track.Path, stat.ModTime(), file) -} - -func (c *Controller) GetLicence(w http.ResponseWriter, r *http.Request) { - sub := subsonic.NewResponse() - sub.Licence = &subsonic.Licence{ - Valid: true, - } - respond(w, r, sub) -} - -func (c *Controller) Scrobble(w http.ResponseWriter, r *http.Request) { - id, err := getIntParam(r, "id") - if err != nil { - respondError(w, r, 10, "please provide an `id` parameter") - return - } - // fetch user to get lastfm session - username := getStrParam(r, "u") - user := c.GetUserFromName(username) - if user == nil { - respondError(w, r, 10, "could not find a user with that name") - return - } - 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 - c.DB. - Preload("Album"). - Preload("AlbumArtist"). - First(&track, id) - // get time from args or use now - time := getIntParamOr(r, "time", int(time.Now().Unix())) - // get submission, where the default is true. we will - // check if it's false later - submission := getStrParamOr(r, "submission", "true") - // scrobble with above info - err = lastfm.Scrobble( - c.GetSetting("lastfm_api_key"), - c.GetSetting("lastfm_secret"), - user.LastFMSession, - &track, - time, - submission != "false", - ) - if err != nil { - respondError(w, r, 0, fmt.Sprintf("error when submitting: %v", err)) - return - } - sub := subsonic.NewResponse() - respond(w, r, sub) -} - -func (c *Controller) NotFound(w http.ResponseWriter, r *http.Request) { - respondError(w, r, 0, "unknown route") -} diff --git a/handler/handler_sub_common.go b/handler/handler_sub_common.go new file mode 100644 index 0000000..d7160b4 --- /dev/null +++ b/handler/handler_sub_common.go @@ -0,0 +1,126 @@ +package handler + +import ( + "fmt" + "net/http" + "os" + "time" + "unicode" + + "github.com/rainycape/unidecode" + + "github.com/sentriz/gonic/db" + "github.com/sentriz/gonic/lastfm" + "github.com/sentriz/gonic/subsonic" +) + +func indexOf(s string) rune { + first := string(s[0]) + c := rune(unidecode.Unidecode(first)[0]) + if !unicode.IsLetter(c) { + return '#' + } + return c +} + +func (c *Controller) Stream(w http.ResponseWriter, r *http.Request) { + id, err := getIntParam(r, "id") + if err != nil { + respondError(w, r, 10, "please provide an `id` parameter") + return + } + var track db.Track + c.DB.First(&track, id) + if track.Path == "" { + respondError(w, r, 70, fmt.Sprintf("media with id `%d` was not found", id)) + return + } + file, err := os.Open(track.Path) + if err != nil { + respondError(w, r, 0, fmt.Sprintf("error while streaming media: %v", err)) + return + } + stat, _ := file.Stat() + http.ServeContent(w, r, track.Path, stat.ModTime(), file) +} + +func (c *Controller) GetCoverArt(w http.ResponseWriter, r *http.Request) { + id, err := getIntParam(r, "id") + if err != nil { + respondError(w, r, 10, "please provide an `id` parameter") + return + } + var cover db.Cover + c.DB.First(&cover, id) + w.Write(cover.Image) +} + +func (c *Controller) GetLicence(w http.ResponseWriter, r *http.Request) { + sub := subsonic.NewResponse() + sub.Licence = &subsonic.Licence{ + Valid: true, + } + respond(w, r, sub) +} + +func (c *Controller) Ping(w http.ResponseWriter, r *http.Request) { + sub := subsonic.NewResponse() + respond(w, r, sub) +} + +func (c *Controller) Scrobble(w http.ResponseWriter, r *http.Request) { + id, err := getIntParam(r, "id") + if err != nil { + respondError(w, r, 10, "please provide an `id` parameter") + return + } + // fetch user to get lastfm session + username := getStrParam(r, "u") + user := c.GetUserFromName(username) + if user == nil { + respondError(w, r, 10, "could not find a user with that name") + return + } + 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 + c.DB. + Preload("Album"). + Preload("AlbumArtist"). + First(&track, id) + // get time from args or use now + time := getIntParamOr(r, "time", int(time.Now().Unix())) + // get submission, where the default is true. we will + // check if it's false later + submission := getStrParamOr(r, "submission", "true") + // scrobble with above info + err = lastfm.Scrobble( + c.GetSetting("lastfm_api_key"), + c.GetSetting("lastfm_secret"), + user.LastFMSession, + &track, + time, + submission != "false", + ) + if err != nil { + respondError(w, r, 0, fmt.Sprintf("error when submitting: %v", err)) + return + } + sub := subsonic.NewResponse() + respond(w, r, sub) +} + +func (c *Controller) GetMusicFolders(w http.ResponseWriter, r *http.Request) { + sub := subsonic.NewResponse() + sub.MusicFolders = []*subsonic.MusicFolder{ + {ID: 1, Name: "music"}, + } + respond(w, r, sub) +} + +func (c *Controller) NotFound(w http.ResponseWriter, r *http.Request) { + respondError(w, r, 0, "unknown route") +}