This commit is contained in:
sentriz
2019-05-19 23:28:05 +01:00
parent 5c657d9630
commit ad571ed7ab
45 changed files with 787 additions and 492 deletions

2
.gitignore vendored
View File

@@ -1,8 +1,6 @@
*.db *.db
*-packr.go *-packr.go
packrd/ packrd/
scanner
server
cmd/scanner/scanner cmd/scanner/scanner
cmd/server/server cmd/server/server
_test* _test*

View File

@@ -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 package main
import ( import (
"fmt" "flag"
"io/ioutil"
"log" "log"
"os" "os"
"path"
"time"
"github.com/dhowden/tag"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite" _ "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 ( const (
orm *gorm.DB programName = "gonic"
tx *gorm.DB programVar = "GONIC"
// 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{}
) )
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() { func main() {
if len(os.Args) != 2 { set := flag.NewFlagSet(programName, flag.ExitOnError)
log.Fatalf("usage: %s <path to music>", os.Args[0]) musicPath := set.String(
} "music-path", "",
orm = db.New() "path to music")
orm.SetLogger(log.New(os.Stdout, "gorm ", 0)) dbPath := set.String(
tx = orm.Begin() "db-path", "gonic.db",
createDatabase() "path to database (optional)")
startTime := time.Now() err := ff.Parse(set, os.Args[1:])
err := godirwalk.Walk(os.Args[1], &godirwalk.Options{
Callback: handleItem,
PostChildrenCallback: handleFolderCompletion,
Unsorted: true,
})
if err != nil { 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)) if _, err := os.Stat(*musicPath); os.IsNotExist(err) {
startTime = time.Now() log.Fatal("please provide a valid music directory")
cleanDatabase() }
log.Printf("cleaned in %s\n", time.Since(startTime)) db, err := gorm.Open("sqlite3", *dbPath)
tx.Commit() 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()
} }

View File

@@ -1,162 +1,59 @@
package main package main
import ( import (
"html/template" "flag"
"log" "log"
"net/http" "os"
"time"
"github.com/gobuffalo/packr/v2" "github.com/jinzhu/gorm"
"github.com/gorilla/securecookie"
_ "github.com/jinzhu/gorm/dialects/sqlite" _ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/wader/gormstore" "github.com/peterbourgon/ff"
"github.com/sentriz/gonic/db" "github.com/sentriz/gonic/server"
"github.com/sentriz/gonic/handler"
) )
type middleware func(next http.HandlerFunc) http.HandlerFunc const (
programName = "gonic"
func newChain(wares ...middleware) middleware { programVar = "GONIC"
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))
}
func main() { func main() {
address := "0.0.0.0:6969" set := flag.NewFlagSet(programName, flag.ExitOnError)
mux := http.NewServeMux() listenAddr := set.String(
// create a new controller and pass a copy to both routes. "listen-addr", "0.0.0.0:6969",
// they will add more fields to their copy if they need them "listen address (optional)")
baseController := handler.Controller{DB: db.New()} musicPath := set.String(
setSubsonicRoutes(baseController, mux) "music-path", "",
setAdminRoutes(baseController, mux) "path to music")
server := &http.Server{ dbPath := set.String(
Addr: address, "db-path", "gonic.db",
Handler: mux, "path to database (optional)")
ReadTimeout: 5 * time.Second, _ = set.String(
WriteTimeout: 10 * time.Second, "config-path", "",
IdleTimeout: 15 * time.Second, "path to config (optional)")
} err := ff.Parse(set, os.Args[1:],
log.Printf("starting server at `%s`\n", address) ff.WithConfigFileFlag("config-path"),
err := server.ListenAndServe() ff.WithConfigFileParser(ff.PlainParser),
ff.WithEnvVarPrefix(programVar),
)
if err != nil { 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)
} }
} }

View File

@@ -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
}

2
go.mod
View File

@@ -18,6 +18,8 @@ require (
github.com/karrick/godirwalk v1.8.0 github.com/karrick/godirwalk v1.8.0
github.com/lib/pq v1.0.0 // indirect github.com/lib/pq v1.0.0 // indirect
github.com/mattn/go-sqlite3 v1.10.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/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be
github.com/wader/gormstore v0.0.0-20190302154359-acb787ba3755 github.com/wader/gormstore v0.0.0-20190302154359-acb787ba3755
golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd // indirect golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd // indirect

2
go.sum
View File

@@ -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/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.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
github.com/openzipkin/zipkin-go v0.1.3/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.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 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

View File

@@ -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
}

View File

@@ -1,8 +0,0 @@
package handler
type contextKey int
const (
contextUserKey contextKey = iota
contextSessionKey
)

View File

@@ -11,7 +11,9 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/sentriz/gonic/db" "github.com/pkg/errors"
"github.com/sentriz/gonic/model"
) )
var ( var (
@@ -43,14 +45,14 @@ func makeRequest(method string, params url.Values) (*LastFM, error) {
req.URL.RawQuery = params.Encode() req.URL.RawQuery = params.Encode()
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("get: %v", err) return nil, errors.Wrap(err, "get")
} }
defer resp.Body.Close() defer resp.Body.Close()
decoder := xml.NewDecoder(resp.Body) decoder := xml.NewDecoder(resp.Body)
var lastfm LastFM var lastfm LastFM
err = decoder.Decode(&lastfm) err = decoder.Decode(&lastfm)
if err != nil { if err != nil {
return nil, fmt.Errorf("decoding: %v", err) return nil, errors.Wrap(err, "decoding")
} }
if lastfm.Error != nil { if lastfm.Error != nil {
return nil, fmt.Errorf("parsing: %v", lastfm.Error.Value) 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, 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{} params := url.Values{}
if submission { if submission {
params.Add("method", "track.Scrobble") params.Add("method", "track.Scrobble")

View File

@@ -1,4 +1,4 @@
package db package model
import ( import (
"time" "time"

View File

@@ -1,4 +1,4 @@
package db package model
import "time" import "time"

37
scanner/dir_stack.go Normal file
View File

@@ -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
}

303
scanner/scanner.go Normal file
View File

@@ -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
}

69
scanner/utilities.go Normal file
View File

@@ -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)
}

43
server/handler/handler.go Normal file
View File

@@ -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
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/sentriz/gonic/db" "github.com/sentriz/gonic/model"
"github.com/sentriz/gonic/lastfm" "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) http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return return
} }
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
user.Password = passwordOne user.Password = passwordOne
c.DB.Save(user) c.DB.Save(user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) 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) http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
return return
} }
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
user.LastFMSession = sessionKey user.LastFMSession = sessionKey
c.DB.Save(&user) c.DB.Save(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
} }
func (c *Controller) ServeUnlinkLastFMDo(w http.ResponseWriter, r *http.Request) { 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 = "" user.LastFMSession = ""
c.DB.Save(&user) c.DB.Save(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) 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) http.Error(w, "please provide a username", 400)
return return
} }
var user db.User var user model.User
err := c.DB.Where("name = ?", username).First(&user).Error err := c.DB.Where("name = ?", username).First(&user).Error
if gorm.IsRecordNotFoundError(err) { if gorm.IsRecordNotFoundError(err) {
http.Error(w, "couldn't find a user with that name", 400) 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) { func (c *Controller) ServeChangePasswordDo(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(contextSessionKey).(*sessions.Session)
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
var user db.User var user model.User
c.DB.Where("name = ?", username).First(&user) c.DB.Where("name = ?", username).First(&user)
passwordOne := r.FormValue("password_one") passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two") 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) http.Error(w, "please provide a username", 400)
return return
} }
var user db.User var user model.User
err := c.DB.Where("name = ?", username).First(&user).Error err := c.DB.Where("name = ?", username).First(&user).Error
if gorm.IsRecordNotFoundError(err) { if gorm.IsRecordNotFoundError(err) {
http.Error(w, "couldn't find a user with that name", 400) 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) { func (c *Controller) ServeDeleteUserDo(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
var user db.User var user model.User
c.DB.Where("name = ?", username).First(&user) c.DB.Where("name = ?", username).First(&user)
c.DB.Delete(&user) c.DB.Delete(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) 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) http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return return
} }
user := db.User{ user := model.User{
Name: username, Name: username,
Password: passwordOne, Password: passwordOne,
} }

View File

@@ -6,14 +6,14 @@ import (
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/sentriz/gonic/db" "github.com/sentriz/gonic/model"
"github.com/sentriz/gonic/subsonic" "github.com/sentriz/gonic/server/subsonic"
) )
func (c *Controller) GetIndexes(w http.ResponseWriter, r *http.Request) { func (c *Controller) GetIndexes(w http.ResponseWriter, r *http.Request) {
// we are browsing by folder, but the subsonic docs show sub <artist> elements // we are browsing by folder, but the subsonic docs show sub <artist> elements
// for this, so we're going to return root directories as "artists" // 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) c.DB.Where("parent_id = ?", 1).Find(&folders)
var indexMap = make(map[rune]*subsonic.Index) var indexMap = make(map[rune]*subsonic.Index)
var indexes []*subsonic.Index var indexes []*subsonic.Index
@@ -48,11 +48,11 @@ func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) {
return return
} }
childrenObj := []*subsonic.Child{} childrenObj := []*subsonic.Child{}
var cFolder db.Folder var cFolder model.Folder
c.DB.First(&cFolder, id) c.DB.First(&cFolder, id)
// //
// start looking for child folders in the current dir // start looking for child folders in the current dir
var folders []*db.Folder var folders []*model.Folder
c.DB. c.DB.
Where("parent_id = ?", id). Where("parent_id = ?", id).
Find(&folders) 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 // start looking for child tracks in the current dir
var tracks []*db.Track var tracks []*model.Track
c.DB. c.DB.
Where("folder_id = ?", id). Where("folder_id = ?", id).
Preload("Album"). 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 // not sure about "name" either, so lets use the folder's name
q = q.Order("name") q = q.Order("name")
case "frequent": case "frequent":
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
q = q.Joins(` q = q.Joins(`
JOIN plays JOIN plays
ON folders.id = plays.folder_id AND plays.user_id = ?`, 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": case "random":
q = q.Order(gorm.Expr("random()")) q = q.Order(gorm.Expr("random()"))
case "recent": case "recent":
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
q = q.Joins(` q = q.Joins(`
JOIN plays JOIN plays
ON folders.id = plays.folder_id AND plays.user_id = ?`, 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 return
} }
var folders []*db.Folder var folders []*model.Folder
q. q.
Where("folders.has_tracks = 1"). Where("folders.has_tracks = 1").
Offset(getIntParamOr(r, "offset", 0)). Offset(getIntParamOr(r, "offset", 0)).

View File

@@ -6,12 +6,12 @@ import (
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/sentriz/gonic/db" "github.com/sentriz/gonic/model"
"github.com/sentriz/gonic/subsonic" "github.com/sentriz/gonic/server/subsonic"
) )
func (c *Controller) GetArtists(w http.ResponseWriter, r *http.Request) { func (c *Controller) GetArtists(w http.ResponseWriter, r *http.Request) {
var artists []*db.AlbumArtist var artists []*model.AlbumArtist
c.DB.Find(&artists) c.DB.Find(&artists)
var indexMap = make(map[rune]*subsonic.Index) var indexMap = make(map[rune]*subsonic.Index)
var indexes subsonic.Artists 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") respondError(w, r, 10, "please provide an `id` parameter")
return return
} }
var artist db.AlbumArtist var artist model.AlbumArtist
c.DB. c.DB.
Preload("Albums"). Preload("Albums").
First(&artist, id) 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") respondError(w, r, 10, "please provide an `id` parameter")
return return
} }
var album db.Album var album model.Album
c.DB. c.DB.
Preload("AlbumArtist"). Preload("AlbumArtist").
Preload("Tracks"). Preload("Tracks").
@@ -132,7 +132,7 @@ func (c *Controller) GetAlbumListTwo(w http.ResponseWriter, r *http.Request) {
getIntParamOr(r, "toYear", 2200)) getIntParamOr(r, "toYear", 2200))
q = q.Order("year") q = q.Order("year")
case "frequent": case "frequent":
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
q = q.Joins(` q = q.Joins(`
JOIN plays JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`, 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": case "random":
q = q.Order(gorm.Expr("random()")) q = q.Order(gorm.Expr("random()"))
case "recent": case "recent":
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
q = q.Joins(` q = q.Joins(`
JOIN plays JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`, 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 return
} }
var albums []*db.Album var albums []*model.Album
q. q.
Offset(getIntParamOr(r, "offset", 0)). Offset(getIntParamOr(r, "offset", 0)).
Limit(getIntParamOr(r, "size", 10)). Limit(getIntParamOr(r, "size", 10)).

View File

@@ -4,14 +4,16 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"sync/atomic"
"time" "time"
"unicode" "unicode"
"github.com/rainycape/unidecode" "github.com/rainycape/unidecode"
"github.com/sentriz/gonic/db"
"github.com/sentriz/gonic/lastfm" "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 { 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") respondError(w, r, 10, "please provide an `id` parameter")
return return
} }
var track db.Track var track model.Track
c.DB. c.DB.
Preload("Album"). Preload("Album").
Preload("Folder"). 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) http.ServeContent(w, r, track.Path, stat.ModTime(), file)
// //
// after we've served the file, mark the album as played // after we've served the file, mark the album as played
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
play := db.Play{ play := model.Play{
AlbumID: track.Album.ID, AlbumID: track.Album.ID,
FolderID: track.Folder.ID, FolderID: track.Folder.ID,
UserID: user.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") respondError(w, r, 10, "please provide an `id` parameter")
return return
} }
var cover db.Cover var cover model.Cover
c.DB.First(&cover, id) c.DB.First(&cover, id)
w.Write(cover.Image) w.Write(cover.Image)
} }
@@ -90,13 +92,13 @@ func (c *Controller) Scrobble(w http.ResponseWriter, r *http.Request) {
return return
} }
// fetch user to get lastfm session // fetch user to get lastfm session
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
if user.LastFMSession == "" { if user.LastFMSession == "" {
respondError(w, r, 0, fmt.Sprintf("no last.fm session for this user: %v", err)) respondError(w, r, 0, fmt.Sprintf("no last.fm session for this user: %v", err))
return return
} }
// fetch track for getting info to send to last.fm function // fetch track for getting info to send to last.fm function
var track db.Track var track model.Track
c.DB. c.DB.
Preload("Album"). Preload("Album").
Preload("AlbumArtist"). Preload("AlbumArtist").
@@ -133,6 +135,23 @@ func (c *Controller) GetMusicFolders(w http.ResponseWriter, r *http.Request) {
respond(w, r, sub) 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) { func (c *Controller) NotFound(w http.ResponseWriter, r *http.Request) {
respondError(w, r, 0, "unknown route") respondError(w, r, 0, "unknown route")
} }

View File

@@ -6,12 +6,12 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/sentriz/gonic/db" "github.com/sentriz/gonic/model"
) )
func (c *Controller) WithSession(next http.HandlerFunc) http.HandlerFunc { func (c *Controller) WithSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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) withSession := context.WithValue(r.Context(), contextSessionKey, session)
next.ServeHTTP(w, r.WithContext(withSession)) 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) { return func(w http.ResponseWriter, r *http.Request) {
// session and user exist at this point // session and user exist at this point
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(contextSessionKey).(*sessions.Session)
user := r.Context().Value(contextUserKey).(*db.User) user := r.Context().Value(contextUserKey).(*model.User)
if !user.IsAdmin { if !user.IsAdmin {
session.AddFlash("you are not an admin") session.AddFlash("you are not an admin")
session.Save(r, w) session.Save(r, w)

View File

@@ -7,14 +7,14 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/sentriz/gonic/db" "github.com/sentriz/gonic/model"
) )
type templateData struct { type templateData struct {
Flashes []interface{} Flashes []interface{}
User *db.User User *model.User
SelectedUser *db.User SelectedUser *model.User
AllUsers []*db.User AllUsers []*model.User
ArtistCount int ArtistCount int
AlbumCount int AlbumCount int
TrackCount int TrackCount int
@@ -31,7 +31,7 @@ func renderTemplate(w http.ResponseWriter, r *http.Request,
} }
data.Flashes = session.Flashes() data.Flashes = session.Flashes()
session.Save(r, w) session.Save(r, w)
user, ok := r.Context().Value(contextUserKey).(*db.User) user, ok := r.Context().Value(contextUserKey).(*model.User)
if ok { if ok {
data.User = user data.User = user
} }

View File

@@ -6,7 +6,7 @@ import (
"log" "log"
"net/http" "net/http"
"github.com/sentriz/gonic/subsonic" "github.com/sentriz/gonic/server/subsonic"
) )
func respondRaw(w http.ResponseWriter, r *http.Request, func respondRaw(w http.ResponseWriter, r *http.Request,

54
server/server.go Normal file
View File

@@ -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
}

83
server/setup_admin.go Normal file
View File

@@ -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))
}

44
server/setup_subsonic.go Normal file
View File

@@ -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))
}

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 236 KiB

View File

@@ -116,3 +116,8 @@ type MusicFolder struct {
type Licence struct { type Licence struct {
Valid bool `xml:"valid,attr,omitempty" json:"valid,omitempty"` 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"`
}

View File

@@ -29,6 +29,7 @@ type Response struct {
Directory *Directory `xml:"directory" json:"directory,omitempty"` Directory *Directory `xml:"directory" json:"directory,omitempty"`
RandomTracks *RandomTracks `xml:"randomSongs" json:"randomSongs,omitempty"` RandomTracks *RandomTracks `xml:"randomSongs" json:"randomSongs,omitempty"`
MusicFolders *MusicFolders `xml:"musicFolders" json:"musicFolders,omitempty"` MusicFolders *MusicFolders `xml:"musicFolders" json:"musicFolders,omitempty"`
ScanStatus *ScanStatus `xml:"scanStatus" json:"scanStatus,omitempty"`
Licence *Licence `xml:"license" json:"license,omitempty"` Licence *Licence `xml:"license" json:"license,omitempty"`
} }