From 123f543a94361cca7694711f088edafb66c16770 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jan 2020 16:53:48 -0500 Subject: [PATCH] New Folder Scanner - WIP --- .gitignore | 2 +- Makefile | 2 +- conf/configuration.go | 2 + go.mod | 2 - go.sum | 8 - log/log.go | 2 +- model/album.go | 1 + model/artist.go | 1 + model/index.go | 5 +- model/mediafile.go | 1 + persistence/album_repository.go | 60 +++++- persistence/artist_repository.go | 62 ++++++ persistence/index_repository.go | 79 +++++--- persistence/index_repository_test.go | 5 - persistence/mediafile_repository.go | 20 ++ scanner/change_detector.go | 113 +++++++++++ scanner/change_detector_test.go | 103 ++++++++++ scanner/metadata.go | 140 ++++++++++++++ scanner/metadata_test.go | 55 ++++++ scanner/scanner.go | 121 ++++++++++++ scanner/scanner_suite_test.go | 36 ++++ scanner/tag_scanner.go | 273 +++++++++++++++++++++++++++ server/app.go | 50 +++-- tests/fixtures/test.mp3 | Bin 0 -> 60845 bytes tests/fixtures/test.ogg | Bin 0 -> 4408 bytes wire_gen.go | 7 +- wire_injectors.go | 2 + 27 files changed, 1092 insertions(+), 60 deletions(-) create mode 100644 scanner/change_detector.go create mode 100644 scanner/change_detector_test.go create mode 100644 scanner/metadata.go create mode 100644 scanner/metadata_test.go create mode 100644 scanner/scanner.go create mode 100644 scanner/scanner_suite_test.go create mode 100644 scanner/tag_scanner.go create mode 100644 tests/fixtures/test.mp3 create mode 100644 tests/fixtures/test.ogg diff --git a/.gitignore b/.gitignore index 1bf1521d8..81e98dea8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ Artwork sonic.toml master.zip Jamstash-master -storm.db +testDB diff --git a/Makefile b/Makefile index f820f2cbd..4bc25696b 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ test: check_go_env .PHONY: build build: check_go_env go build - @(cd ./ui && npm run build) +# @(cd ./ui && npm run build) .PHONY: setup setup: Jamstash-master diff --git a/conf/configuration.go b/conf/configuration.go index 11e26bd28..d6f703c51 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -20,6 +20,7 @@ type sonic struct { DisableDownsampling bool `default:"false"` DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"` + ProbeCommand string `default:"ffprobe -v quiet -print_format json -show_format %s"` PlsIgnoreFolders bool `default:"true"` PlsIgnoredPatterns string `default:"^iCloud;\\~"` @@ -28,6 +29,7 @@ type sonic struct { DevDisableAuthentication bool `default:"false"` DevDisableFileCheck bool `default:"false"` DevDisableBanner bool `default:"false"` + DevUseFileScanner bool `default:"false"` } var Sonic *sonic diff --git a/go.mod b/go.mod index a409a03bf..19c11f0ea 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/Masterminds/squirrel v1.1.0 github.com/astaxie/beego v1.12.0 github.com/bradleyjkemp/cupaloy v2.3.0+incompatible - github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131 github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a // indirect github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 @@ -15,7 +14,6 @@ require ( github.com/fatih/structs v1.0.0 // indirect github.com/go-chi/chi v4.0.3+incompatible github.com/go-chi/cors v1.0.0 - github.com/google/uuid v1.1.1 github.com/google/wire v0.4.0 github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629 github.com/koding/multiconfig v0.0.0-20170327155832-26b6dfd3a84a diff --git a/go.sum b/go.sum index 55e2fc553..4b3823f1b 100644 --- a/go.sum +++ b/go.sum @@ -22,14 +22,10 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4 h1:VSoAcWJvj656TSyWbJ5KuGsi/J8dO5+iO9+5/7I8wao= -github.com/deluan/rest v0.0.0-20200114062534-0653ffe9eab4/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131 h1:siEGb+iB1Ea75U7BnkYVSqSRzE6QHlXCbqEXenxRmhQ= github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131/go.mod h1:eVWQJVQ67aMvYhpkDwaH2Goy2vo6v8JCMfGXfQ9sPtw= github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a h1:7MucP9rMAsQRcRE1sGpvMZoTxFYZlDmfDvCH+z7H+90= github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a/go.mod h1:sLjdR6uwx3L6/Py8F+QgAfeiuY87xuYGwCDqRFrvCzw= -github.com/dhowden/tag v0.0.0-20170128231422-9edd38ca5d10 h1:H4waV2XHwsud7v90IfEbwwynwVlC7YU3RicLlfkfgn4= -github.com/dhowden/tag v0.0.0-20170128231422-9edd38ca5d10/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw= github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8 h1:nmFnmD8VZXkjDHIb1Gnfz50cgzUvGN72zLjPRXBW/hU= github.com/dhowden/tag v0.0.0-20191122115059-7e5c04feccd8/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw= github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8= @@ -58,8 +54,6 @@ github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8l github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= -github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.4.0 h1:kXcsA/rIGzJImVqPdhfnr6q0xsS9gU0515q1EPpJ9fE= github.com/google/wire v0.4.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= @@ -84,8 +78,6 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtB github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= -github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= diff --git a/log/log.go b/log/log.go index 2f9d143d3..cb502e3e5 100644 --- a/log/log.go +++ b/log/log.go @@ -132,7 +132,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) { kvPairs := args[1:] l = addFields(l, kvPairs) } - if currentLevel >= LevelDebug { + if currentLevel >= LevelTrace { _, file, line, ok := runtime.Caller(2) if !ok { file = "???" diff --git a/model/album.go b/model/album.go index 79c166424..cbed93015 100644 --- a/model/album.go +++ b/model/album.go @@ -37,4 +37,5 @@ type AlbumRepository interface { GetAllIds() ([]string, error) GetStarred(...QueryOptions) (Albums, error) Search(q string, offset int, size int) (Albums, error) + Refresh(ids ...string) error } diff --git a/model/artist.go b/model/artist.go index 9c4cf5745..426f670af 100644 --- a/model/artist.go +++ b/model/artist.go @@ -13,6 +13,7 @@ type ArtistRepository interface { Get(id string) (*Artist, error) PurgeInactive(active Artists) error Search(q string, offset int, size int) (Artists, error) + Refresh(ids ...string) error } type Artists []Artist diff --git a/model/index.go b/model/index.go index 72d762628..9ea79a731 100644 --- a/model/index.go +++ b/model/index.go @@ -14,11 +14,10 @@ type ArtistIndex struct { type ArtistInfos []ArtistInfo type ArtistIndexes []ArtistIndex +// TODO Combine ArtistIndex with Artist type ArtistIndexRepository interface { - CountAll() (int64, error) - Exists(id string) (bool, error) Put(m *ArtistIndex) error - Get(id string) (*ArtistIndex, error) + Refresh() error GetAll() (ArtistIndexes, error) DeleteAll() error } diff --git a/model/mediafile.go b/model/mediafile.go index 4ace8cb1f..d64f11794 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -51,4 +51,5 @@ type MediaFileRepository interface { GetAllIds() ([]string, error) Search(q string, offset int, size int) (MediaFiles, error) Delete(id string) error + DeleteByPath(path string) error } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 7e7b5473f..49078d676 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -1,9 +1,12 @@ package persistence import ( + "fmt" + "strings" "time" "github.com/astaxie/beego/orm" + "github.com/cloudsonic/sonic-server/log" "github.com/cloudsonic/sonic-server/model" ) @@ -85,7 +88,62 @@ func (r *albumRepository) toAlbums(all []Album) model.Albums { return result } -// TODO Remove []string from return +func (r *albumRepository) Refresh(ids ...string) error { + type refreshAlbum struct { + Album + CurrentId string + HasCoverArt bool + } + var albums []refreshAlbum + o := Db() + sql := fmt.Sprintf(` +select album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.compilation, f.genre, + max(f.year) as year, sum(f.play_count) as play_count, max(f.play_date) as play_date, sum(f.duration) as duration, + max(f.updated_at) as updated_at, min(f.created_at) as created_at, count(*) as song_count, + a.id as current_id, f.id as cover_art_id, f.path as cover_art_path, f.has_cover_art +from media_file f left outer join album a on f.album_id = a.id +where f.album_id in ('%s') +group by album_id order by f.id`, strings.Join(ids, "','")) + _, err := o.Raw(sql).QueryRows(&albums) + if err != nil { + return err + } + + var toInsert []Album + var toUpdate []Album + for _, al := range albums { + if !al.HasCoverArt { + al.CoverArtId = "" + } + if al.Compilation { + al.AlbumArtist = "Various Artists" + } + if al.CurrentId != "" { + toUpdate = append(toUpdate, al.Album) + } else { + toInsert = append(toInsert, al.Album) + } + } + if len(toInsert) > 0 { + n, err := o.InsertMulti(100, toInsert) + if err != nil { + return err + } + log.Debug("Inserted new albums", "num", n) + } + if len(toUpdate) > 0 { + for _, al := range toUpdate { + _, err := o.Update(&al, "name", "artist_id", "cover_art_path", "cover_art_id", "artist", "album_artist", "year", + "compilation", "play_count", "song_count", "duration", "updated_at") + if err != nil { + return err + } + } + log.Debug("Updated albums", "num", len(toUpdate)) + } + return err +} + func (r *albumRepository) PurgeInactive(activeList model.Albums) error { return withTx(func(o orm.Ormer) error { _, err := r.purgeInactive(o, activeList, func(item interface{}) string { diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index cccb5469a..b1955f337 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -1,7 +1,11 @@ package persistence import ( + "fmt" + "strings" + "github.com/astaxie/beego/orm" + "github.com/cloudsonic/sonic-server/log" "github.com/cloudsonic/sonic-server/model" ) @@ -42,6 +46,64 @@ func (r *artistRepository) Get(id string) (*model.Artist, error) { return &a, nil } +func (r *artistRepository) Refresh(ids ...string) error { + type refreshArtist struct { + Artist + CurrentId string + AlbumArtist string + Compilation bool + } + var artists []refreshArtist + o := Db() + sql := fmt.Sprintf(` +select f.artist_id as id, + f.artist as name, + f.album_artist, + f.compilation, + count(*) as album_count, + a.id as current_id +from album f + left outer join artist a on f.artist_id = a.id +where f.artist_id in ('%s') group by f.artist_id order by f.id`, strings.Join(ids, "','")) + _, err := o.Raw(sql).QueryRows(&artists) + if err != nil { + return err + } + + var toInsert []Artist + var toUpdate []Artist + for _, al := range artists { + if al.Compilation { + al.AlbumArtist = "Various Artists" + } + if al.AlbumArtist != "" { + al.Name = al.AlbumArtist + } + if al.CurrentId != "" { + toUpdate = append(toUpdate, al.Artist) + } else { + toInsert = append(toInsert, al.Artist) + } + } + if len(toInsert) > 0 { + n, err := o.InsertMulti(100, toInsert) + if err != nil { + return err + } + log.Debug("Inserted new artists", "num", n) + } + if len(toUpdate) > 0 { + for _, al := range toUpdate { + _, err := o.Update(&al, "name", "album_count") + if err != nil { + return err + } + } + log.Debug("Updated artists", "num", len(toUpdate)) + } + return err +} + func (r *artistRepository) PurgeInactive(activeList model.Artists) error { return withTx(func(o orm.Ormer) error { _, err := r.purgeInactive(o, activeList, func(item interface{}) string { diff --git a/persistence/index_repository.go b/persistence/index_repository.go index 96d3b7ae8..1ba8a4f2b 100644 --- a/persistence/index_repository.go +++ b/persistence/index_repository.go @@ -2,9 +2,12 @@ package persistence import ( "sort" + "strings" "github.com/astaxie/beego/orm" + "github.com/cloudsonic/sonic-server/conf" "github.com/cloudsonic/sonic-server/model" + "github.com/cloudsonic/sonic-server/utils" ) type ArtistInfo struct { @@ -15,6 +18,8 @@ type ArtistInfo struct { AlbumCount int } +type tempIndex map[string]model.ArtistInfo + type artistIndexRepository struct { sqlRepository } @@ -25,15 +30,6 @@ func NewArtistIndexRepository() model.ArtistIndexRepository { return r } -func (r *artistIndexRepository) CountAll() (int64, error) { - count := struct{ Count int64 }{} - err := Db().Raw("select count(distinct(idx)) as count from artist_info").QueryRow(&count) - if err != nil { - return 0, err - } - return count.Count, nil -} - func (r *artistIndexRepository) Put(idx *model.ArtistIndex) error { return withTx(func(o orm.Ormer) error { _, err := r.newQuery(o).Filter("idx", idx.ID).Delete() @@ -56,23 +52,64 @@ func (r *artistIndexRepository) Put(idx *model.ArtistIndex) error { }) } -func (r *artistIndexRepository) Get(id string) (*model.ArtistIndex, error) { - var ais []ArtistInfo - _, err := r.newQuery(Db()).Filter("idx", id).All(&ais) +func (r *artistIndexRepository) Refresh() error { + o := Db() + + indexGroups := utils.ParseIndexGroups(conf.Sonic.IndexGroups) + artistIndex := make(map[string]tempIndex) + + var artists []Artist + _, err := o.QueryTable(&Artist{}).All(&artists) if err != nil { - return nil, err + return err } - idx := &model.ArtistIndex{ID: id} - idx.Artists = make([]model.ArtistInfo, len(ais)) - for i, a := range ais { - idx.Artists[i] = model.ArtistInfo{ - ArtistID: a.ArtistID, - Artist: a.Artist, - AlbumCount: a.AlbumCount, + for _, ar := range artists { + r.collectIndex(indexGroups, &ar, artistIndex) + } + + return r.saveIndex(artistIndex) +} + +func (r *artistIndexRepository) collectIndex(ig utils.IndexGroups, a *Artist, artistIndex map[string]tempIndex) { + name := a.Name + indexName := strings.ToLower(utils.NoArticle(name)) + if indexName == "" { + return + } + group := r.findGroup(ig, indexName) + artists := artistIndex[group] + if artists == nil { + artists = make(tempIndex) + artistIndex[group] = artists + } + artists[indexName] = model.ArtistInfo{ArtistID: a.ID, Artist: a.Name, AlbumCount: a.AlbumCount} +} + +func (r *artistIndexRepository) findGroup(ig utils.IndexGroups, name string) string { + for k, v := range ig { + key := strings.ToLower(k) + if strings.HasPrefix(name, key) { + return v } } - return idx, err + return "#" +} + +func (r *artistIndexRepository) saveIndex(artistIndex map[string]tempIndex) error { + r.DeleteAll() + for k, temp := range artistIndex { + idx := &model.ArtistIndex{ID: k} + for _, v := range temp { + idx.Artists = append(idx.Artists, v) + } + err := r.Put(idx) + if err != nil { + return err + } + } + + return nil } func (r *artistIndexRepository) GetAll() (model.ArtistIndexes, error) { diff --git a/persistence/index_repository_test.go b/persistence/index_repository_test.go index 1697bb8a6..f53660e6a 100644 --- a/persistence/index_repository_test.go +++ b/persistence/index_repository_test.go @@ -35,11 +35,6 @@ var _ = Describe("Artist Index", func() { Expect(repo.Put(&idx1)).To(BeNil()) Expect(repo.Put(&idx2)).To(BeNil()) - Expect(repo.Get("D")).To(Equal(&idx1)) - Expect(repo.Get("S")).To(Equal(&idx2)) Expect(repo.GetAll()).To(Equal(model.ArtistIndexes{idx1, idx2})) - Expect(repo.CountAll()).To(Equal(int64(2))) - Expect(repo.DeleteAll()).To(BeNil()) - Expect(repo.CountAll()).To(Equal(int64(0))) }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 44146fdff..92d6c7150 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -102,6 +102,26 @@ func (r *mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) return r.toMediaFiles(filtered), nil } +func (r *mediaFileRepository) DeleteByPath(path string) error { + o := Db() + var mfs []MediaFile + _, err := r.newQuery(o).Filter("path__istartswith", path).OrderBy("disc_number", "track_number").All(&mfs) + if err != nil { + return err + } + var filtered []string + path = strings.ToLower(path) + string(os.PathSeparator) + for _, mf := range mfs { + filename := strings.TrimPrefix(strings.ToLower(mf.Path), path) + if len(strings.Split(filename, string(os.PathSeparator))) > 1 { + continue + } + filtered = append(filtered, mf.ID) + } + _, err = r.newQuery(o).Filter("id__in", filtered).Delete() + return err +} + func (r *mediaFileRepository) GetStarred(options ...model.QueryOptions) (model.MediaFiles, error) { var starred []MediaFile _, err := r.newQuery(Db(), options...).Filter("starred", true).All(&starred) diff --git a/scanner/change_detector.go b/scanner/change_detector.go new file mode 100644 index 000000000..4a337f207 --- /dev/null +++ b/scanner/change_detector.go @@ -0,0 +1,113 @@ +package scanner + +import ( + "os" + "path" + "strings" + "time" + + "github.com/cloudsonic/sonic-server/log" +) + +type ChangeDetector struct { + rootFolder string + lastUpdate time.Time + dirMap map[string]time.Time +} + +func NewChangeDetector(rootFolder string, lastUpdate time.Time) *ChangeDetector { + return &ChangeDetector{ + rootFolder: rootFolder, + lastUpdate: lastUpdate, + dirMap: map[string]time.Time{}, + } +} + +func (s *ChangeDetector) Scan() (changed []string, deleted []string, err error) { + start := time.Now() + newMap := make(map[string]time.Time) + err = s.loadMap(s.rootFolder, newMap) + if err != nil { + return + } + changed, deleted, err = s.checkForUpdates(s.dirMap, newMap) + if err != nil { + return + } + elapsed := time.Since(start) + + log.Trace("Folder analysis complete\n", "total", len(newMap), "changed", len(changed), "deleted", len(deleted), "elapsed", elapsed) + s.dirMap = newMap + return +} + +func (s *ChangeDetector) loadDir(dirPath string) (children []string, lastUpdated time.Time, err error) { + dir, err := os.Open(dirPath) + if err != nil { + return + } + dirInfo, err := os.Stat(dirPath) + if err != nil { + return + } + lastUpdated = dirInfo.ModTime() + + files, err := dir.Readdir(-1) + if err != nil { + return + } + for _, f := range files { + if f.IsDir() { + children = append(children, path.Join(dirPath, f.Name())) + } else { + if f.ModTime().After(lastUpdated) { + lastUpdated = f.ModTime() + } + } + } + return +} + +func (s *ChangeDetector) loadMap(rootPath string, dirMap map[string]time.Time) error { + children, lastUpdated, err := s.loadDir(rootPath) + if err != nil { + return err + } + for _, c := range children { + err := s.loadMap(c, dirMap) + if err != nil { + return err + } + } + + dir := s.getRelativePath(rootPath) + dirMap[dir] = lastUpdated + + return nil +} + +func (s *ChangeDetector) getRelativePath(subfolder string) string { + dir := strings.TrimPrefix(subfolder, s.rootFolder) + if dir == "" { + dir = "." + } + return dir +} + +func (s *ChangeDetector) checkForUpdates(oldMap map[string]time.Time, newMap map[string]time.Time) (changed []string, deleted []string, err error) { + for dir, lastUpdated := range newMap { + oldLastUpdated, ok := oldMap[dir] + if !ok { + oldLastUpdated = s.lastUpdate + } + if lastUpdated.After(oldLastUpdated) { + changed = append(changed, dir) + } + } + for dir := range oldMap { + if _, ok := newMap[dir]; !ok { + deleted = append(deleted, dir) + } + } + return +} diff --git a/scanner/change_detector_test.go b/scanner/change_detector_test.go new file mode 100644 index 000000000..623069910 --- /dev/null +++ b/scanner/change_detector_test.go @@ -0,0 +1,103 @@ +package scanner + +import ( + "io/ioutil" + "os" + "path" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("ChangeDetector", func() { + var testFolder string + var scanner *ChangeDetector + + BeforeEach(func() { + testFolder, _ = ioutil.TempDir("", "cloudsonic_tests") + err := os.MkdirAll(testFolder, 0700) + if err != nil { + panic(err) + } + scanner = NewChangeDetector(testFolder, time.Time{}) + }) + + It("detects changes recursively", func() { + // Scan empty folder + changed, deleted, err := scanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(BeEmpty()) + Expect(changed).To(ConsistOf(".")) + + // Add one subfolder + err = os.MkdirAll(path.Join(testFolder, "a"), 0700) + if err != nil { + panic(err) + } + changed, deleted, err = scanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(BeEmpty()) + Expect(changed).To(ConsistOf(".", "/a")) + + // Add more subfolders + err = os.MkdirAll(path.Join(testFolder, "a", "b", "c"), 0700) + if err != nil { + panic(err) + } + changed, deleted, err = scanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(BeEmpty()) + Expect(changed).To(ConsistOf("/a", "/a/b", "/a/b/c")) + + // Scan with no changes + changed, deleted, err = scanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(BeEmpty()) + Expect(changed).To(BeEmpty()) + + // New file in subfolder + _, err = os.Create(path.Join(testFolder, "a", "b", "empty.txt")) + if err != nil { + panic(err) + } + changed, deleted, err = scanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(BeEmpty()) + Expect(changed).To(ConsistOf("/a/b")) + + // Delete file in subfolder + err = os.Remove(path.Join(testFolder, "a", "b", "empty.txt")) + if err != nil { + panic(err) + } + changed, deleted, err = scanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(BeEmpty()) + Expect(changed).To(ConsistOf("/a/b")) + + // Delete subfolder + err = os.Remove(path.Join(testFolder, "a", "b", "c")) + if err != nil { + panic(err) + } + changed, deleted, err = scanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(ConsistOf("/a/b/c")) + Expect(changed).To(ConsistOf("/a/b")) + + // Only returns changes after lastUpdate + newScanner := NewChangeDetector(testFolder, time.Now()) + changed, deleted, err = newScanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(BeEmpty()) + Expect(changed).To(BeEmpty()) + Expect(changed).To(BeEmpty()) + + _, err = os.Create(path.Join(testFolder, "a", "b", "new.txt")) + changed, deleted, err = newScanner.Scan() + Expect(err).To(BeNil()) + Expect(deleted).To(BeEmpty()) + Expect(changed).To(ConsistOf("/a/b")) + }) +}) diff --git a/scanner/metadata.go b/scanner/metadata.go new file mode 100644 index 000000000..93aa67d45 --- /dev/null +++ b/scanner/metadata.go @@ -0,0 +1,140 @@ +package scanner + +import ( + "encoding/json" + "errors" + "os" + "os/exec" + "path" + "strconv" + "strings" + "time" + + "github.com/cloudsonic/sonic-server/conf" + "github.com/cloudsonic/sonic-server/log" + "github.com/dhowden/tag" +) + +type Metadata struct { + filePath string + suffix string + fileInfo os.FileInfo + t tag.Metadata + duration int + bitRate int + compilation bool +} + +func ExtractMetadata(filePath string) (*Metadata, error) { + m := &Metadata{filePath: filePath} + m.suffix = strings.ToLower(strings.TrimPrefix(path.Ext(filePath), ".")) + fi, err := os.Stat(filePath) + if err != nil { + return nil, err + } + m.fileInfo = fi + + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + t, err := tag.ReadFrom(f) + if err != nil { + return nil, err + } + m.t = t + + err = m.probe(filePath) + return m, err +} + +func (m *Metadata) Title() string { return m.t.Title() } +func (m *Metadata) Album() string { return m.t.Album() } +func (m *Metadata) Artist() string { return m.t.Artist() } +func (m *Metadata) AlbumArtist() string { return m.t.AlbumArtist() } +func (m *Metadata) Composer() string { return m.t.Composer() } +func (m *Metadata) Genre() string { return m.t.Genre() } +func (m *Metadata) Year() int { return m.t.Year() } +func (m *Metadata) TrackNumber() (int, int) { return m.t.Track() } +func (m *Metadata) DiscNumber() (int, int) { return m.t.Disc() } +func (m *Metadata) HasPicture() bool { return m.t.Picture() != nil } +func (m *Metadata) Compilation() bool { return m.compilation } +func (m *Metadata) Duration() int { return m.duration } +func (m *Metadata) BitRate() int { return m.bitRate } +func (m *Metadata) ModificationTime() time.Time { return m.fileInfo.ModTime() } +func (m *Metadata) FilePath() string { return m.filePath } +func (m *Metadata) Suffix() string { return m.suffix } +func (m *Metadata) Size() int { return int(m.fileInfo.Size()) } + +// probe analyzes the file and returns duration in seconds and bitRate in kb/s. +// It uses the ffprobe external tool, configured in conf.Sonic.ProbeCommand +func (m *Metadata) probe(filePath string) error { + cmdLine, args := createProbeCommand(filePath) + + log.Trace("Executing command", "cmdLine", cmdLine, "args", args) + cmd := exec.Command(cmdLine, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return err + } + return m.parseOutput(output) +} + +func (m *Metadata) parseInt(objItf interface{}, field string) (int, error) { + obj := objItf.(map[string]interface{}) + s, ok := obj[field].(string) + if !ok { + return -1, errors.New("invalid ffprobe output field obj." + field) + } + fDuration, err := strconv.ParseFloat(s, 64) + if err != nil { + return -1, err + } + return int(fDuration), nil +} + +func (m *Metadata) parseOutput(output []byte) error { + var data map[string]map[string]interface{} + err := json.Unmarshal(output, &data) + if err != nil { + return err + } + + format, ok := data["format"] + if !ok { + err = errors.New("invalid ffprobe output. no format found") + return err + } + + if tags, ok := format["tags"]; ok { + c, _ := m.parseInt(tags, "compilation") + m.compilation = c == 1 + } + + m.duration, err = m.parseInt(format, "duration") + if err != nil { + return err + } + + m.bitRate, err = m.parseInt(format, "bit_rate") + m.bitRate = m.bitRate / 1000 + if err != nil { + return err + } + + return nil +} + +func createProbeCommand(filePath string) (string, []string) { + cmd := conf.Sonic.ProbeCommand + + split := strings.Split(cmd, " ") + for i, s := range split { + s = strings.Replace(s, "%s", filePath, -1) + split[i] = s + } + + return split[0], split[1:] +} diff --git a/scanner/metadata_test.go b/scanner/metadata_test.go new file mode 100644 index 000000000..fbc89ca4d --- /dev/null +++ b/scanner/metadata_test.go @@ -0,0 +1,55 @@ +package scanner + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Metadata", func() { + It("correctly parses mp3 file", func() { + m, err := ExtractMetadata("../tests/fixtures/test.mp3") + Expect(err).To(BeNil()) + Expect(m.Title()).To(Equal("Song")) + Expect(m.Album()).To(Equal("Album")) + Expect(m.Artist()).To(Equal("Artist")) + Expect(m.AlbumArtist()).To(Equal("Album Artist")) + Expect(m.Composer()).To(Equal("Composer")) + Expect(m.Compilation()).To(BeFalse()) + Expect(m.Genre()).To(Equal("Rock")) + Expect(m.Year()).To(Equal(2014)) + n, t := m.TrackNumber() + Expect(n).To(Equal(2)) + Expect(t).To(Equal(10)) + n, t = m.DiscNumber() + Expect(n).To(Equal(1)) + Expect(t).To(Equal(2)) + Expect(m.HasPicture()).To(BeTrue()) + Expect(m.Duration()).To(Equal(1)) + Expect(m.BitRate()).To(Equal(476)) + Expect(m.FilePath()).To(Equal("../tests/fixtures/test.mp3")) + Expect(m.Suffix()).To(Equal("mp3")) + Expect(m.Size()).To(Equal(60845)) + }) + + It("correctly parses ogg file with no tags", func() { + m, err := ExtractMetadata("../tests/fixtures/test.ogg") + Expect(err).To(BeNil()) + Expect(m.Title()).To(BeEmpty()) + Expect(m.HasPicture()).To(BeFalse()) + Expect(m.Duration()).To(Equal(3)) + Expect(m.BitRate()).To(Equal(9)) + Expect(m.Suffix()).To(Equal("ogg")) + Expect(m.FilePath()).To(Equal("../tests/fixtures/test.ogg")) + Expect(m.Size()).To(Equal(4408)) + }) + + It("returns error for invalid media file", func() { + _, err := ExtractMetadata("../tests/fixtures/itunes-library.xml") + Expect(err).ToNot(BeNil()) + }) + + It("returns error for file not found", func() { + _, err := ExtractMetadata("../tests/fixtures/NOT-FOUND.mp3") + Expect(err).ToNot(BeNil()) + }) +}) diff --git a/scanner/scanner.go b/scanner/scanner.go new file mode 100644 index 000000000..ef0156cda --- /dev/null +++ b/scanner/scanner.go @@ -0,0 +1,121 @@ +package scanner + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/cloudsonic/sonic-server/log" + "github.com/cloudsonic/sonic-server/model" +) + +type Scanner struct { + folders map[string]FolderScanner + repos Repositories +} + +type Repositories struct { + folder model.MediaFolderRepository + mediaFile model.MediaFileRepository + album model.AlbumRepository + artist model.ArtistRepository + index model.ArtistIndexRepository + playlist model.PlaylistRepository + property model.PropertyRepository +} + +func New(mfRepo model.MediaFileRepository, albumRepo model.AlbumRepository, artistRepo model.ArtistRepository, idxRepo model.ArtistIndexRepository, plsRepo model.PlaylistRepository, folderRepo model.MediaFolderRepository, property model.PropertyRepository) *Scanner { + repos := Repositories{ + folder: folderRepo, + mediaFile: mfRepo, + album: albumRepo, + artist: artistRepo, + index: idxRepo, + playlist: plsRepo, + property: property, + } + s := &Scanner{repos: repos, folders: map[string]FolderScanner{}} + s.loadFolders() + return s +} + +func (s *Scanner) Rescan(mediaFolder string, fullRescan bool) error { + folderScanner := s.folders[mediaFolder] + start := time.Now() + + lastModifiedSince := time.Time{} + if !fullRescan { + lastModifiedSince = s.getLastModifiedSince(mediaFolder) + log.Debug("Scanning folder", "folder", mediaFolder, "lastModifiedSince", lastModifiedSince) + } else { + log.Debug("Scanning folder (full scan)", "folder", mediaFolder) + } + + err := folderScanner.Scan(nil, lastModifiedSince) + if err != nil { + log.Error("Error importing MediaFolder", "folder", mediaFolder, err) + } + + s.updateLastModifiedSince(mediaFolder, start) + log.Debug("Finished scanning folder", "folder", mediaFolder, "elapsed", time.Since(start)) + return err +} + +func (s *Scanner) RescanAll(fullRescan bool) error { + var hasError bool + for folder := range s.folders { + err := s.Rescan(folder, fullRescan) + hasError = hasError || err != nil + } + if hasError { + log.Error("Errors while scanning media. Please check the logs") + return errors.New("errors while scanning media") + } + return nil +} + +func (s *Scanner) Status() []StatusInfo { return nil } + +func (i *Scanner) getLastModifiedSince(folder string) time.Time { + ms, err := i.repos.property.Get(model.PropLastScan + "-" + folder) + if err != nil { + return time.Time{} + } + if ms == "" { + return time.Time{} + } + s, _ := strconv.ParseInt(ms, 10, 64) + return time.Unix(0, s*int64(time.Millisecond)) +} + +func (s *Scanner) updateLastModifiedSince(folder string, t time.Time) { + millis := t.UnixNano() / int64(time.Millisecond) + s.repos.property.Put(model.PropLastScan+"-"+folder, fmt.Sprint(millis)) +} + +func (s *Scanner) loadFolders() { + fs, _ := s.repos.folder.GetAll() + for _, f := range fs { + log.Info("Configuring Media Folder", "name", f.Name, "path", f.Path) + s.folders[f.Path] = NewTagScanner(f.Path, s.repos) + } +} + +type Status int + +const ( + StatusComplete Status = iota + StatusInProgress + StatusError +) + +type StatusInfo struct { + MediaFolder string + Status Status +} + +type FolderScanner interface { + Scan(ctx context.Context, lastModifiedSince time.Time) error +} diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go new file mode 100644 index 000000000..9d216f9fc --- /dev/null +++ b/scanner/scanner_suite_test.go @@ -0,0 +1,36 @@ +package scanner + +import ( + "testing" + "time" + + "github.com/cloudsonic/sonic-server/conf" + "github.com/cloudsonic/sonic-server/log" + "github.com/cloudsonic/sonic-server/persistence" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestScanner(t *testing.T) { + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "Scanner Suite") +} + +var _ = XDescribe("TODO: REMOVE", func() { + conf.Sonic.DbPath = "./testDB" + log.SetLevel(log.LevelDebug) + repos := Repositories{ + folder: persistence.NewMediaFolderRepository(), + mediaFile: persistence.NewMediaFileRepository(), + album: persistence.NewAlbumRepository(), + artist: persistence.NewArtistRepository(), + index: persistence.NewArtistIndexRepository(), + playlist: nil, + } + It("WORKS!", func() { + t := NewTagScanner("/Users/deluan/Music/iTunes/iTunes Media/Music", repos) + //t := NewTagScanner("/Users/deluan/Development/cloudsonic/sonic-server/tests/fixtures", repos) + Expect(t.Scan(nil, time.Time{})).To(BeNil()) + }) +}) diff --git a/scanner/tag_scanner.go b/scanner/tag_scanner.go new file mode 100644 index 000000000..41ff9ced9 --- /dev/null +++ b/scanner/tag_scanner.go @@ -0,0 +1,273 @@ +package scanner + +import ( + "context" + "crypto/md5" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/cloudsonic/sonic-server/log" + "github.com/cloudsonic/sonic-server/model" +) + +type TagScanner struct { + rootFolder string + repos Repositories +} + +func NewTagScanner(rootFolder string, repos Repositories) *TagScanner { + return &TagScanner{ + rootFolder: rootFolder, + repos: repos, + } +} + +// Scan algorithm overview: +// For each changed: Get all files from DB that starts with it, scan each file: +// if changed or new, delete from DB and add new from the file +// if not found, delete from DB +// scan and add the new ones +// For each deleted: delete all files from DB that starts with it +// Create new albums/artists, update counters (how?) +// collect all albumids and artistids from previous steps +// run something like this (for albums): +// select album_id, album, f.artist, f.compilation, max(f.year), count(*), sum(f.play_count), max(f.updated_at), a.id from media_file f left outer join album a on f.album_id = a.id group by album_id; +// when a.id is not null update, else insert (collect all inserts and run just one InsertMulti) +// Delete all empty albums, delete all empty Artists +// Recreate ArtistIndex +func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) error { + detector := NewChangeDetector(s.rootFolder, lastModifiedSince) + changed, deleted, err := detector.Scan() + if err != nil { + return err + } + + if len(changed)+len(deleted) == 0 { + return nil + } + + log.Info("Folder changes found", "changed", len(changed), "deleted", len(deleted)) + + updatedArtists := map[string]bool{} + updatedAlbums := map[string]bool{} + + for _, c := range changed { + err := s.processChangedDir(c, updatedArtists, updatedAlbums) + if err != nil { + return err + } + } + for _, c := range deleted { + err := s.processDeletedDir(c, updatedArtists, updatedAlbums) + if err != nil { + return err + } + } + + err = s.refreshAlbums(updatedAlbums) + if err != nil { + return err + } + + err = s.refreshArtists(updatedArtists) + if err != nil { + return err + } + + err = s.repos.index.Refresh() + if err != nil { + return err + } + + return nil +} + +func (s *TagScanner) refreshAlbums(updatedAlbums map[string]bool) error { + var ids []string + for id := range updatedAlbums { + ids = append(ids, id) + } + return s.repos.album.Refresh(ids...) +} + +func (s *TagScanner) refreshArtists(updatedArtists map[string]bool) error { + var ids []string + for id := range updatedArtists { + ids = append(ids, id) + } + return s.repos.artist.Refresh(ids...) +} + +func (s *TagScanner) processChangedDir(dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error { + dir = path.Join(s.rootFolder, dir) + + start := time.Now() + + // Load folder's current tracks from DB into a map + currentTracks := map[string]model.MediaFile{} + ct, err := s.repos.mediaFile.FindByPath(dir) + if err != nil { + return err + } + for _, t := range ct { + currentTracks[t.ID] = t + updatedArtists[t.ArtistID] = true + updatedAlbums[t.AlbumID] = true + } + + // Load tracks from the folder + newTracks, err := s.loadTracks(dir) + if err != nil { + return err + } + + // If track from folder is newer than the one in DB, update/insert in DB and delete from the current tracks + log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(newTracks)) + numUpdatedTracks := 0 + numPurgedTracks := 0 + for _, n := range newTracks { + c, ok := currentTracks[n.ID] + if !ok || (ok && n.UpdatedAt.After(c.UpdatedAt)) { + err := s.repos.mediaFile.Put(&n) + updatedArtists[n.ArtistID] = true + updatedAlbums[n.AlbumID] = true + numUpdatedTracks++ + if err != nil { + return err + } + } + delete(currentTracks, n.ID) + } + + // Remaining tracks from DB that are not in the folder are deleted + for id := range currentTracks { + numPurgedTracks++ + if err := s.repos.mediaFile.Delete(id); err != nil { + return err + } + } + + log.Debug("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start)) + return nil +} + +func (s *TagScanner) processDeletedDir(dir string, updatedArtists map[string]bool, updatedAlbums map[string]bool) error { + dir = path.Join(s.rootFolder, dir) + + ct, err := s.repos.mediaFile.FindByPath(dir) + if err != nil { + return err + } + for _, t := range ct { + updatedArtists[t.ArtistID] = true + updatedAlbums[t.AlbumID] = true + } + + return s.repos.mediaFile.DeleteByPath(dir) +} + +func (s *TagScanner) loadTracks(dirPath string) (model.MediaFiles, error) { + dir, err := os.Open(dirPath) + if err != nil { + return nil, err + } + files, err := dir.Readdir(-1) + if err != nil { + return nil, err + } + var mds model.MediaFiles + for _, f := range files { + if f.IsDir() { + continue + } + filePath := path.Join(dirPath, f.Name()) + md, err := ExtractMetadata(filePath) + if err != nil { + continue + } + mf := s.toMediaFile(md) + mds = append(mds, mf) + } + return mds, nil +} + +func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile { + mf := model.MediaFile{} + mf.ID = s.trackID(md) + mf.Title = s.mapTrackTitle(md) + mf.Album = md.Album() + mf.AlbumID = s.albumID(md) + mf.Album = s.mapAlbumName(md) + if md.Artist() == "" { + mf.Artist = "[Unknown Artist]" + } else { + mf.Artist = md.Artist() + } + mf.ArtistID = s.artistID(md) + mf.AlbumArtist = md.AlbumArtist() + mf.Genre = md.Genre() + mf.Compilation = md.Compilation() + mf.Year = md.Year() + mf.TrackNumber, _ = md.TrackNumber() + mf.DiscNumber, _ = md.DiscNumber() + mf.Duration = md.Duration() + mf.BitRate = md.BitRate() + mf.Path = md.FilePath() + mf.Suffix = md.Suffix() + mf.Size = strconv.Itoa(md.Size()) + mf.HasCoverArt = md.HasPicture() + + // TODO Get Creation time. https://github.com/djherbis/times ? + mf.CreatedAt = md.ModificationTime() + mf.UpdatedAt = md.ModificationTime() + + return mf +} + +func (s *TagScanner) mapTrackTitle(md *Metadata) string { + if md.Title() == "" { + s := strings.TrimPrefix(md.FilePath(), s.rootFolder+string(os.PathSeparator)) + e := filepath.Ext(s) + return strings.TrimSuffix(s, e) + } + return md.Title() +} + +func (s *TagScanner) mapArtistName(md *Metadata) string { + switch { + case md.Compilation(): + return "Various Artists" + case md.AlbumArtist() != "": + return md.AlbumArtist() + case md.Artist() != "": + return md.Artist() + default: + return "[Unknown Artist]" + } +} + +func (s *TagScanner) mapAlbumName(md *Metadata) string { + name := md.Album() + if name == "" { + return "[Unknown Album]" + } + return name +} + +func (s *TagScanner) trackID(md *Metadata) string { + return fmt.Sprintf("%x", md5.Sum([]byte(md.FilePath()))) +} + +func (s *TagScanner) albumID(md *Metadata) string { + albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapArtistName(md), s.mapAlbumName(md))) + return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) +} + +func (s *TagScanner) artistID(md *Metadata) string { + return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md))))) +} diff --git a/server/app.go b/server/app.go index d8408e5f7..7ffc2f9b5 100644 --- a/server/app.go +++ b/server/app.go @@ -9,6 +9,7 @@ import ( "github.com/cloudsonic/sonic-server/conf" "github.com/cloudsonic/sonic-server/log" + "github.com/cloudsonic/sonic-server/scanner" "github.com/cloudsonic/sonic-server/scanner_legacy" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" @@ -19,17 +20,24 @@ const Version = "0.2" type Server struct { Importer *scanner_legacy.Importer + Scanner *scanner.Scanner router *chi.Mux } -func New(importer *scanner_legacy.Importer) *Server { - a := &Server{Importer: importer} +func New(importer *scanner_legacy.Importer, scanner *scanner.Scanner) *Server { + a := &Server{Importer: importer, Scanner: scanner} if !conf.Sonic.DevDisableBanner { showBanner(Version) } initMimeTypes() a.initRoutes() - a.initImporter() + if conf.Sonic.DevUseFileScanner { + log.Info("Using Folder Scanner", "folder", conf.Sonic.MusicFolder) + a.initScanner() + } else { + log.Info("Using iTunes Importer", "xml", conf.Sonic.MusicFolder) + a.initImporter() + } return a } @@ -67,22 +75,34 @@ func (a *Server) initRoutes() { a.router = r } -func (a *Server) initImporter() { - go a.startPeriodicScans() +func (a *Server) initScanner() { + go func() { + for { + select { + case <-time.After(5 * time.Second): + err := a.Scanner.RescanAll(false) + if err != nil { + log.Error("Error scanning media folder", "folder", conf.Sonic.MusicFolder, err) + } + } + } + }() } -func (a *Server) startPeriodicScans() { - first := true - for { - select { - case <-time.After(5 * time.Second): - if first { - log.Info("Started iTunes scanner", "xml", conf.Sonic.MusicFolder) - first = false +func (a *Server) initImporter() { + go func() { + first := true + for { + select { + case <-time.After(5 * time.Second): + if first { + log.Info("Started iTunes scanner", "xml", conf.Sonic.MusicFolder) + first = false + } + a.Importer.CheckForUpdates(false) } - a.Importer.CheckForUpdates(false) } - } + }() } func FileServer(r chi.Router, path string, root http.FileSystem) { diff --git a/tests/fixtures/test.mp3 b/tests/fixtures/test.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..dd88083180d9929e20053a5bfaa9d7b8a44b69c6 GIT binary patch literal 60845 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