refactor: update scanner, scanner tests, mockfs

closes #165
closes #163
This commit is contained in:
sentriz
2021-11-03 23:05:08 +00:00
parent b07b9a8be6
commit fa587fc7de
64 changed files with 3469 additions and 2373 deletions

View File

@@ -27,7 +27,7 @@ func firstExisting(or string, strings ...string) string {
func doScan(scanner *scanner.Scanner, opts scanner.ScanOptions) {
go func() {
if err := scanner.Start(opts); err != nil {
if err := scanner.ScanAndClean(opts); err != nil {
log.Printf("error while scanning: %v\n", err)
}
}()
@@ -43,11 +43,11 @@ func (c *Controller) ServeLogin(r *http.Request) *Response {
func (c *Controller) ServeHome(r *http.Request) *Response {
data := &templateData{}
// ** begin stats box
c.DB.Table("artists").Count(&data.ArtistCount)
c.DB.Table("albums").Count(&data.AlbumCount)
// stats box
c.DB.Model(&db.Artist{}).Count(&data.ArtistCount)
c.DB.Model(&db.Album{}).Count(&data.AlbumCount)
c.DB.Table("tracks").Count(&data.TrackCount)
// ** begin lastfm box
// lastfm box
scheme := firstExisting(
"http", // fallback
r.Header.Get("X-Forwarded-Proto"),
@@ -60,36 +60,37 @@ func (c *Controller) ServeHome(r *http.Request) *Response {
r.Host,
)
data.RequestRoot = fmt.Sprintf("%s://%s", scheme, host)
data.CurrentLastFMAPIKey = c.DB.GetSetting("lastfm_api_key")
data.CurrentLastFMAPIKey, _ = c.DB.GetSetting("lastfm_api_key")
data.DefaultListenBrainzURL = listenbrainz.BaseURL
// ** begin users box
// users box
c.DB.Find(&data.AllUsers)
// ** begin recent folders box
// recent folders box
c.DB.
Where("tag_artist_id IS NOT NULL").
Order("modified_at DESC").
Limit(8).
Find(&data.RecentFolders)
data.IsScanning = scanner.IsScanning()
if tStr := c.DB.GetSetting("last_scan_time"); tStr != "" {
data.IsScanning = c.Scanner.IsScanning()
if tStr, err := c.DB.GetSetting("last_scan_time"); err != nil {
i, _ := strconv.ParseInt(tStr, 10, 64)
data.LastScanTime = time.Unix(i, 0)
}
//
user := r.Context().Value(CtxUser).(*db.User)
// ** begin playlists box
// playlists box
c.DB.
Where("user_id=?", user.ID).
Limit(20).
Find(&data.Playlists)
// ** begin transcoding box
// transcoding box
c.DB.
Where("user_id=?", user.ID).
Find(&data.TranscodePreferences)
for profile := range encode.Profiles() {
data.TranscodeProfiles = append(data.TranscodeProfiles, profile)
}
// ** begin podcasts box
// podcasts box
c.DB.Find(&data.Podcasts)
//
return &Response{
@@ -143,11 +144,15 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response {
code: 400,
}
}
sessionKey, err := lastfm.GetSession(
c.DB.GetSetting("lastfm_api_key"),
c.DB.GetSetting("lastfm_secret"),
token,
)
apiKey, err := c.DB.GetSetting("lastfm_api_key")
if err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't get api key: %v", err)}
}
secret, err := c.DB.GetSetting("lastfm_secret")
if err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't get secret: %v", err)}
}
sessionKey, err := lastfm.GetSession(apiKey, secret, token)
if err != nil {
return &Response{
redirect: "/admin/home",
@@ -341,8 +346,13 @@ func (c *Controller) ServeCreateUserDo(r *http.Request) *Response {
func (c *Controller) ServeUpdateLastFMAPIKey(r *http.Request) *Response {
data := &templateData{}
data.CurrentLastFMAPIKey = c.DB.GetSetting("lastfm_api_key")
data.CurrentLastFMAPISecret = c.DB.GetSetting("lastfm_secret")
var err error
if data.CurrentLastFMAPIKey, err = c.DB.GetSetting("lastfm_api_key"); err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't get api key: %v", err)}
}
if data.CurrentLastFMAPISecret, err = c.DB.GetSetting("lastfm_secret"); err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't get secret: %v", err)}
}
return &Response{
template: "update_lastfm_api_key.tmpl",
data: data,
@@ -358,8 +368,12 @@ func (c *Controller) ServeUpdateLastFMAPIKeyDo(r *http.Request) *Response {
flashW: []string{err.Error()},
}
}
c.DB.SetSetting("lastfm_api_key", apiKey)
c.DB.SetSetting("lastfm_secret", secret)
if err := c.DB.SetSetting("lastfm_api_key", apiKey); err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't set api key: %v", err)}
}
if err := c.DB.SetSetting("lastfm_secret", secret); err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't set secret: %v", err)}
}
return &Response{redirect: "/admin/home"}
}

View File

@@ -30,7 +30,7 @@ func playlistParseLine(c *Controller, path string) (int, error) {
c.MusicPath, path)
err := query.First(&track).Error
switch {
case gorm.IsRecordNotFoundError(err):
case errors.Is(err, gorm.ErrRecordNotFound):
return 0, fmt.Errorf("%v: %w", err, errPlaylistNoMatch)
case err != nil:
return 0, fmt.Errorf("while matching: %w", err)

View File

@@ -29,6 +29,7 @@ type Controller struct {
*ctrlbase.Controller
CachePath string
CoverCachePath string
PodcastsPath string
Jukebox *jukebox.Jukebox
Scrobblers []scrobble.Scrobbler
Podcasts *podcasts.Podcasts

View File

@@ -2,6 +2,7 @@ package ctrlsubsonic
import (
"context"
"io/ioutil"
"log"
"net/http"
"net/http/httptest"
@@ -16,14 +17,12 @@ import (
"go.senan.xyz/gonic/server/ctrlbase"
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/mockfs"
)
var (
testDataDir = "testdata"
testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])")
testDBPath = path.Join(testDataDir, "db")
testController *Controller
testDataDir = "testdata"
testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])")
)
type queryCase struct {
@@ -53,18 +52,26 @@ func makeHTTPMock(query url.Values) (*httptest.ResponseRecorder, *http.Request)
return rr, req
}
func runQueryCases(t *testing.T, h handlerSubsonic, cases []*queryCase) {
func runQueryCases(t *testing.T, contr *Controller, h handlerSubsonic, cases []*queryCase) {
t.Helper()
for _, qc := range cases {
qc := qc // pin
t.Run(qc.expectPath, func(t *testing.T) {
t.Parallel()
rr, req := makeHTTPMock(qc.params)
testController.H(h).ServeHTTP(rr, req)
contr.H(h).ServeHTTP(rr, req)
body := rr.Body.String()
if status := rr.Code; status != http.StatusOK {
t.Fatalf("didn't give a 200\n%s", body)
}
goldenPath := makeGoldenPath(t.Name())
goldenRegen := os.Getenv("GONIC_REGEN")
if goldenRegen == "*" || (goldenRegen != "" && strings.HasPrefix(t.Name(), goldenRegen)) {
_ = os.WriteFile(goldenPath, []byte(body), 0600)
t.Logf("golden file %q regenerated for %s", goldenPath, t.Name())
t.SkipNow()
}
// read case to differ with handler result
expected, err := jd.ReadJsonFile(goldenPath)
if err != nil {
@@ -88,13 +95,26 @@ func runQueryCases(t *testing.T, h handlerSubsonic, cases []*queryCase) {
}
}
func makeController(t *testing.T) (*Controller, *mockfs.MockFS) { return makec(t, []string{""}) }
func makeControllerRoots(t *testing.T, r []string) (*Controller, *mockfs.MockFS) { return makec(t, r) }
func makec(t *testing.T, roots []string) (*Controller, *mockfs.MockFS) {
t.Helper()
m := mockfs.NewWithDirs(t, roots)
for _, root := range roots {
m.AddItemsPrefixWithCovers(root)
}
m.ScanAndClean()
m.ResetDates()
m.LogAlbums()
base := &ctrlbase.Controller{DB: m.DB()}
return &Controller{Controller: base}, m
}
func TestMain(m *testing.M) {
db, err := db.New(testDBPath)
if err != nil {
log.Fatalf("error opening database: %v\n", err)
}
testController = &Controller{
Controller: &ctrlbase.Controller{DB: db},
}
log.SetOutput(ioutil.Discard)
os.Exit(m.Run())
}

View File

@@ -1,6 +1,7 @@
package ctrlsubsonic
import (
"errors"
"net/http"
"github.com/jinzhu/gorm"
@@ -18,7 +19,7 @@ func (c *Controller) ServeGetBookmarks(r *http.Request) *spec.Response {
Where("user_id=?", user.ID).
Find(&bookmarks).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return spec.NewResponse()
}
sub := spec.NewResponse()

View File

@@ -61,7 +61,7 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response {
childrenObj := []*spec.TrackChild{}
folder := &db.Album{}
c.DB.First(folder, id.Value)
// ** begin start looking for child childFolders in the current dir
// start looking for child childFolders in the current dir
var childFolders []*db.Album
c.DB.
Where("parent_id=?", id.Value).
@@ -70,7 +70,7 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response {
for _, c := range childFolders {
childrenObj = append(childrenObj, spec.NewTCAlbumByFolder(c))
}
// ** begin start looking for child childTracks in the current dir
// start looking for child childTracks in the current dir
var childTracks []*db.Track
c.DB.
Where("album_id=?", id.Value).
@@ -86,7 +86,7 @@ func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response {
}
childrenObj = append(childrenObj, toAppend)
}
// ** begin respond section
// respond section
sub := spec.NewResponse()
sub.Directory = spec.NewDirectoryByFolder(folder, childrenObj)
return sub
@@ -167,7 +167,7 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response {
}
query = fmt.Sprintf("%%%s%%", strings.TrimSuffix(query, "*"))
results := &spec.SearchResultTwo{}
// ** begin search "artists"
// search "artists"
var artists []*db.Album
c.DB.
Where(`
@@ -182,7 +182,7 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response {
results.Artists = append(results.Artists,
spec.NewDirectoryByFolder(a, nil))
}
// ** begin search "albums"
// search "albums"
var albums []*db.Album
c.DB.
Where(`
@@ -196,7 +196,7 @@ func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response {
for _, a := range albums {
results.Albums = append(results.Albums, spec.NewTCAlbumByFolder(a))
}
// ** begin search tracks
// search tracks
var tracks []*db.Track
c.DB.
Preload("Album").

View File

@@ -8,20 +8,30 @@ import (
)
func TestGetIndexes(t *testing.T) {
runQueryCases(t, testController.ServeGetIndexes, []*queryCase{
contr, m := makeControllerRoots(t, []string{"m-0", "m-1"})
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeGetIndexes, []*queryCase{
{url.Values{}, "no_args", false},
})
}
func TestGetMusicDirectory(t *testing.T) {
runQueryCases(t, testController.ServeGetMusicDirectory, []*queryCase{
contr, m := makeController(t)
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeGetMusicDirectory, []*queryCase{
{url.Values{"id": {"al-2"}}, "without_tracks", false},
{url.Values{"id": {"al-3"}}, "with_tracks", false},
})
}
func TestGetAlbumList(t *testing.T) {
runQueryCases(t, testController.ServeGetAlbumList, []*queryCase{
t.Parallel()
contr, m := makeController(t)
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeGetAlbumList, []*queryCase{
{url.Values{"type": {"alphabeticalByArtist"}}, "alpha_artist", false},
{url.Values{"type": {"alphabeticalByName"}}, "alpha_name", false},
{url.Values{"type": {"newest"}}, "newest", false},
@@ -30,9 +40,13 @@ func TestGetAlbumList(t *testing.T) {
}
func TestSearchTwo(t *testing.T) {
runQueryCases(t, testController.ServeSearchTwo, []*queryCase{
{url.Values{"query": {"13"}}, "q_13", false},
{url.Values{"query": {"ani"}}, "q_ani", false},
{url.Values{"query": {"cert"}}, "q_cert", false},
t.Parallel()
contr, m := makeController(t)
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeSearchTwo, []*queryCase{
{url.Values{"query": {"art"}}, "q_art", false},
{url.Values{"query": {"alb"}}, "q_alb", false},
{url.Values{"query": {"tra"}}, "q_tra", false},
})
}

View File

@@ -1,6 +1,7 @@
package ctrlsubsonic
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -88,7 +89,7 @@ func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response {
}).
First(album, id.Value).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return spec.NewError(10, "couldn't find an album with that id")
}
sub := spec.NewResponse()
@@ -174,7 +175,7 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response {
query = fmt.Sprintf("%%%s%%",
strings.TrimSuffix(query, "*"))
results := &spec.SearchResultThree{}
// ** begin search "artists"
// search "artists"
var artists []*db.Artist
c.DB.
Where("name LIKE ? OR name_u_dec LIKE ?",
@@ -186,7 +187,7 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response {
results.Artists = append(results.Artists,
spec.NewArtistByTags(a))
}
// ** begin search "albums"
// search "albums"
var albums []*db.Album
c.DB.
Preload("TagArtist").
@@ -199,7 +200,7 @@ func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response {
results.Albums = append(results.Albums,
spec.NewAlbumByTags(a, a.TagArtist))
}
// ** begin search tracks
// search tracks
var tracks []*db.Track
c.DB.
Preload("Album").
@@ -223,7 +224,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response {
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
apiKey := c.DB.GetSetting("lastfm_api_key")
apiKey, _ := c.DB.GetSetting("lastfm_api_key")
if apiKey == "" {
sub := spec.NewResponse()
sub.ArtistInfoTwo = &spec.ArtistInfo{}
@@ -234,7 +235,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response {
Where("id=?", id.Value).
Find(artist).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return spec.NewError(70, "artist with id `%s` not found", id)
}
info, err := lastfm.ArtistGetInfo(apiKey, artist)
@@ -271,7 +272,7 @@ func (c *Controller) ServeGetArtistInfoTwo(r *http.Request) *spec.Response {
Group("artists.id").
Find(artist).
Error
if gorm.IsRecordNotFoundError(err) && !inclNotPresent {
if errors.Is(err, gorm.ErrRecordNotFound) && !inclNotPresent {
continue
}
similar := &spec.SimilarArtist{

View File

@@ -6,13 +6,21 @@ import (
)
func TestGetArtists(t *testing.T) {
runQueryCases(t, testController.ServeGetArtists, []*queryCase{
t.Parallel()
contr, m := makeController(t)
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeGetArtists, []*queryCase{
{url.Values{}, "no_args", false},
})
}
func TestGetArtist(t *testing.T) {
runQueryCases(t, testController.ServeGetArtist, []*queryCase{
t.Parallel()
contr, m := makeController(t)
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeGetArtist, []*queryCase{
{url.Values{"id": {"ar-1"}}, "id_one", false},
{url.Values{"id": {"ar-2"}}, "id_two", false},
{url.Values{"id": {"ar-3"}}, "id_three", false},
@@ -20,14 +28,22 @@ func TestGetArtist(t *testing.T) {
}
func TestGetAlbum(t *testing.T) {
runQueryCases(t, testController.ServeGetAlbum, []*queryCase{
t.Parallel()
contr, m := makeController(t)
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeGetAlbum, []*queryCase{
{url.Values{"id": {"al-2"}}, "without_cover", false},
{url.Values{"id": {"al-3"}}, "with_cover", false},
})
}
func TestGetAlbumListTwo(t *testing.T) {
runQueryCases(t, testController.ServeGetAlbumListTwo, []*queryCase{
t.Parallel()
contr, m := makeController(t)
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeGetAlbumListTwo, []*queryCase{
{url.Values{"type": {"alphabeticalByArtist"}}, "alpha_artist", false},
{url.Values{"type": {"alphabeticalByName"}}, "alpha_name", false},
{url.Values{"type": {"newest"}}, "newest", false},
@@ -36,9 +52,13 @@ func TestGetAlbumListTwo(t *testing.T) {
}
func TestSearchThree(t *testing.T) {
runQueryCases(t, testController.ServeSearchThree, []*queryCase{
{url.Values{"query": {"13"}}, "q_13", false},
{url.Values{"query": {"ani"}}, "q_ani", false},
{url.Values{"query": {"cert"}}, "q_cert", false},
t.Parallel()
contr, m := makeController(t)
defer m.CleanUp()
runQueryCases(t, contr, contr.ServeSearchThree, []*queryCase{
{url.Values{"query": {"art"}}, "q_art", false},
{url.Values{"query": {"alb"}}, "q_alb", false},
{url.Values{"query": {"tit"}}, "q_tra", false},
})
}

View File

@@ -1,6 +1,7 @@
package ctrlsubsonic
import (
"errors"
"log"
"net/http"
"time"
@@ -80,7 +81,7 @@ func (c *Controller) ServeGetMusicFolders(r *http.Request) *spec.Response {
func (c *Controller) ServeStartScan(r *http.Request) *spec.Response {
go func() {
if err := c.Scanner.Start(scanner.ScanOptions{}); err != nil {
if err := c.Scanner.ScanAndClean(scanner.ScanOptions{}); err != nil {
log.Printf("error while scanning: %v\n", err)
}
}()
@@ -95,7 +96,7 @@ func (c *Controller) ServeGetScanStatus(r *http.Request) *spec.Response {
sub := spec.NewResponse()
sub.ScanStatus = &spec.ScanStatus{
Scanning: scanner.IsScanning(),
Scanning: c.Scanner.IsScanning(),
Count: trackCount,
}
return sub
@@ -129,7 +130,7 @@ func (c *Controller) ServeGetPlayQueue(r *http.Request) *spec.Response {
Where("user_id=?", user.ID).
Find(&queue).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return spec.NewResponse()
}
sub := spec.NewResponse()
@@ -188,7 +189,7 @@ func (c *Controller) ServeGetSong(r *http.Request) *spec.Response {
Preload("Album").
First(track).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return spec.NewError(10, "couldn't find a track with that id")
}
sub := spec.NewResponse()

View File

@@ -1,6 +1,7 @@
package ctrlsubsonic
import (
"errors"
"log"
"net/http"
"sort"
@@ -33,7 +34,7 @@ func playlistRender(c *Controller, playlist *db.Playlist) *spec.Playlist {
Preload("Album").
Find(&track).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("wasn't able to find track with id %d", id)
continue
}
@@ -68,7 +69,7 @@ func (c *Controller) ServeGetPlaylist(r *http.Request) *spec.Response {
Where("id=?", playlistID).
Find(&playlist).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return spec.NewError(70, "playlist with id `%d` not found", playlistID)
}
sub := spec.NewResponse()

View File

@@ -173,7 +173,7 @@ func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *s
_, err = os.Stat(cachePath)
switch {
case os.IsNotExist(err):
coverPath, err := coverGetPath(c.DB, c.MusicPath, c.Podcasts.PodcastBasePath, id)
coverPath, err := coverGetPath(c.DB, c.PodcastsPath, id)
if err != nil {
return spec.NewError(10, "couldn't find cover `%s`: %v", id, err)
}
@@ -208,7 +208,7 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
case specid.PodcastEpisode:
podcast, err := streamGetPodcast(c.DB, id.Value)
audioFile = podcast
audioPath = path.Join(c.Podcasts.PodcastBasePath, podcast.Path)
audioPath = path.Join(c.PodcastsPath, podcast.Path)
if err != nil {
return spec.NewError(70, "podcast with id `%s` was not found", id)
}
@@ -285,7 +285,7 @@ func (c *Controller) ServeDownload(w http.ResponseWriter, r *http.Request) *spec
case specid.PodcastEpisode:
podcast, err := streamGetPodcast(c.DB, id.Value)
audioFile = podcast
filePath = path.Join(c.Podcasts.PodcastBasePath, podcast.Path)
filePath = path.Join(c.PodcastsPath, podcast.Path)
if err != nil {
return spec.NewError(70, "podcast with id `%s` was not found", id)
}

Binary file not shown.

View File

@@ -6,133 +6,121 @@
"albumList": {
"album": [
{
"id": "al-8",
"coverArt": "al-8",
"artist": "13th Floor Lowervators",
"title": "(1967) Easter Nowhere",
"id": "al-2",
"coverArt": "al-2",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-7",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00"
},
{
"id": "al-9",
"coverArt": "al-9",
"artist": "13th Floor Lowervators",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"album": "",
"parent": "al-7",
"isDir": true,
"name": "",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00"
},
{
"id": "al-5",
"coverArt": "al-5",
"artist": "A Certain Ratio",
"title": "(1994) The Graveyard and the Ballroom",
"album": "",
"parent": "al-4",
"isDir": true,
"name": "",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00"
},
{
"id": "al-6",
"artist": "A Certain Ratio",
"title": "(1981) To EachOTHER.",
"album": "",
"parent": "al-4",
"isDir": true,
"name": "",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": "al-21",
"coverArt": "al-21",
"artist": "Captain Beefheart",
"title": "(1970) Lick My Decals Off, Bitch",
"album": "",
"parent": "al-20",
"isDir": true,
"name": "",
"songCount": 15,
"duration": 2324,
"created": "2019-06-10T19:26:30.944742894+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-3",
"coverArt": "al-3",
"artist": "Jah Wobble, The Edge, Holger Czukay",
"title": "(1983) Snake Charmer",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-2",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-16",
"coverArt": "al-16",
"artist": "Swell Maps",
"title": "(1980) Jane From Occupied Europe",
"id": "al-4",
"coverArt": "al-4",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-15",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 16,
"duration": 3040,
"created": "2019-04-30T16:48:48+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-17",
"coverArt": "al-17",
"artist": "Swell Maps",
"title": "(1979) A Trip to Marineville",
"id": "al-7",
"coverArt": "al-7",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-15",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 18,
"duration": 3266,
"created": "2019-04-30T16:48:48+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-19",
"coverArt": "al-19",
"artist": "Ten Years After",
"title": "(1967) Ten Years After",
"id": "al-8",
"coverArt": "al-8",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-18",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 15,
"duration": 3812,
"created": "2019-04-30T16:48:30+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-9",
"coverArt": "al-9",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-11",
"coverArt": "al-11",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-12",
"coverArt": "al-12",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-13",
"coverArt": "al-13",
"artist": "There",
"title": "(2010) Anika",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-12",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 9,
"duration": 2169,
"created": "2019-05-23T15:12:02.921473302+01:00"
"songCount": 3,
"duration": 300
}
]
}

View File

@@ -6,133 +6,121 @@
"albumList": {
"album": [
{
"id": "al-9",
"coverArt": "al-9",
"artist": "13th Floor Lowervators",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"id": "al-2",
"coverArt": "al-2",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-7",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-8",
"coverArt": "al-8",
"artist": "13th Floor Lowervators",
"title": "(1967) Easter Nowhere",
"id": "al-7",
"coverArt": "al-7",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-7",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-19",
"coverArt": "al-19",
"artist": "Ten Years After",
"title": "(1967) Ten Years After",
"id": "al-11",
"coverArt": "al-11",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-18",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 15,
"duration": 3812,
"created": "2019-04-30T16:48:30+01:00"
},
{
"id": "al-21",
"coverArt": "al-21",
"artist": "Captain Beefheart",
"title": "(1970) Lick My Decals Off, Bitch",
"album": "",
"parent": "al-20",
"isDir": true,
"name": "",
"songCount": 15,
"duration": 2324,
"created": "2019-06-10T19:26:30.944742894+01:00"
},
{
"id": "al-17",
"coverArt": "al-17",
"artist": "Swell Maps",
"title": "(1979) A Trip to Marineville",
"album": "",
"parent": "al-15",
"isDir": true,
"name": "",
"songCount": 18,
"duration": 3266,
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": "al-16",
"coverArt": "al-16",
"artist": "Swell Maps",
"title": "(1980) Jane From Occupied Europe",
"album": "",
"parent": "al-15",
"isDir": true,
"name": "",
"songCount": 16,
"duration": 3040,
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": "al-6",
"artist": "A Certain Ratio",
"title": "(1981) To EachOTHER.",
"album": "",
"parent": "al-4",
"isDir": true,
"name": "",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-3",
"coverArt": "al-3",
"artist": "Jah Wobble, The Edge, Holger Czukay",
"title": "(1983) Snake Charmer",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-2",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-5",
"coverArt": "al-5",
"artist": "A Certain Ratio",
"title": "(1994) The Graveyard and the Ballroom",
"id": "al-8",
"coverArt": "al-8",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-4",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-12",
"coverArt": "al-12",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-4",
"coverArt": "al-4",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-9",
"coverArt": "al-9",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-13",
"coverArt": "al-13",
"artist": "There",
"title": "(2010) Anika",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-12",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 9,
"duration": 2169,
"created": "2019-05-23T15:12:02.921473302+01:00"
"songCount": 3,
"duration": 300
}
]
}

View File

@@ -6,133 +6,121 @@
"albumList": {
"album": [
{
"id": "al-8",
"coverArt": "al-8",
"artist": "13th Floor Lowervators",
"title": "(1967) Easter Nowhere",
"id": "al-2",
"coverArt": "al-2",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-7",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00"
},
{
"id": "al-9",
"coverArt": "al-9",
"artist": "13th Floor Lowervators",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"album": "",
"parent": "al-7",
"isDir": true,
"name": "",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00"
},
{
"id": "al-21",
"coverArt": "al-21",
"artist": "Captain Beefheart",
"title": "(1970) Lick My Decals Off, Bitch",
"album": "",
"parent": "al-20",
"isDir": true,
"name": "",
"songCount": 15,
"duration": 2324,
"created": "2019-06-10T19:26:30.944742894+01:00"
},
{
"id": "al-5",
"coverArt": "al-5",
"artist": "A Certain Ratio",
"title": "(1994) The Graveyard and the Ballroom",
"album": "",
"parent": "al-4",
"isDir": true,
"name": "",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00"
},
{
"id": "al-6",
"artist": "A Certain Ratio",
"title": "(1981) To EachOTHER.",
"album": "",
"parent": "al-4",
"isDir": true,
"name": "",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": "al-13",
"coverArt": "al-13",
"artist": "There",
"title": "(2010) Anika",
"album": "",
"parent": "al-12",
"isDir": true,
"name": "",
"songCount": 9,
"duration": 2169,
"created": "2019-05-23T15:12:02.921473302+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-3",
"coverArt": "al-3",
"artist": "Jah Wobble, The Edge, Holger Czukay",
"title": "(1983) Snake Charmer",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-2",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-16",
"coverArt": "al-16",
"artist": "Swell Maps",
"title": "(1980) Jane From Occupied Europe",
"id": "al-4",
"coverArt": "al-4",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-15",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 16,
"duration": 3040,
"created": "2019-04-30T16:48:48+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-17",
"coverArt": "al-17",
"artist": "Swell Maps",
"title": "(1979) A Trip to Marineville",
"id": "al-7",
"coverArt": "al-7",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-15",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 18,
"duration": 3266,
"created": "2019-04-30T16:48:48+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-19",
"coverArt": "al-19",
"artist": "Ten Years After",
"title": "(1967) Ten Years After",
"id": "al-8",
"coverArt": "al-8",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-18",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 15,
"duration": 3812,
"created": "2019-04-30T16:48:30+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-9",
"coverArt": "al-9",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-11",
"coverArt": "al-11",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-12",
"coverArt": "al-12",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-13",
"coverArt": "al-13",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
}
]
}

View File

@@ -6,133 +6,121 @@
"albumList": {
"album": [
{
"id": "al-19",
"coverArt": "al-19",
"artist": "Ten Years After",
"title": "(1967) Ten Years After",
"id": "al-2",
"coverArt": "al-2",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-18",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 15,
"duration": 3812,
"created": "2019-04-30T16:48:30+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-21",
"coverArt": "al-21",
"artist": "Captain Beefheart",
"title": "(1970) Lick My Decals Off, Bitch",
"id": "al-7",
"coverArt": "al-7",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-20",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 15,
"duration": 2324,
"created": "2019-06-10T19:26:30.944742894+01:00"
},
{
"id": "al-17",
"coverArt": "al-17",
"artist": "Swell Maps",
"title": "(1979) A Trip to Marineville",
"album": "",
"parent": "al-15",
"isDir": true,
"name": "",
"songCount": 18,
"duration": 3266,
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": "al-6",
"artist": "A Certain Ratio",
"title": "(1981) To EachOTHER.",
"album": "",
"parent": "al-4",
"isDir": true,
"name": "",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-13",
"coverArt": "al-13",
"artist": "There",
"title": "(2010) Anika",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-12",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 9,
"duration": 2169,
"created": "2019-05-23T15:12:02.921473302+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-8",
"coverArt": "al-8",
"artist": "13th Floor Lowervators",
"title": "(1967) Easter Nowhere",
"id": "al-11",
"coverArt": "al-11",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-0",
"album": "",
"parent": "al-7",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00"
},
{
"id": "al-9",
"coverArt": "al-9",
"artist": "13th Floor Lowervators",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"album": "",
"parent": "al-7",
"isDir": true,
"name": "",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00"
},
{
"id": "al-5",
"coverArt": "al-5",
"artist": "A Certain Ratio",
"title": "(1994) The Graveyard and the Ballroom",
"album": "",
"parent": "al-4",
"isDir": true,
"name": "",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00"
},
{
"id": "al-16",
"coverArt": "al-16",
"artist": "Swell Maps",
"title": "(1980) Jane From Occupied Europe",
"album": "",
"parent": "al-15",
"isDir": true,
"name": "",
"songCount": 16,
"duration": 3040,
"created": "2019-04-30T16:48:48+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-3",
"coverArt": "al-3",
"artist": "Jah Wobble, The Edge, Holger Czukay",
"title": "(1983) Snake Charmer",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-2",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00"
"songCount": 3,
"duration": 300
},
{
"id": "al-12",
"coverArt": "al-12",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-10",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-8",
"coverArt": "al-8",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-1",
"album": "",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-4",
"coverArt": "al-4",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-1",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
},
{
"id": "al-9",
"coverArt": "al-9",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "album-2",
"album": "",
"parent": "al-6",
"isDir": true,
"name": "",
"songCount": 3,
"duration": 300
}
]
}

View File

@@ -6,133 +6,121 @@
"albumList2": {
"album": [
{
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"id": "al-2",
"coverArt": "al-2",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Easter Everywhere",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00",
"year": 1967
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"title": "",
"album": "",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00",
"year": 1966
},
{
"id": "al-5",
"coverArt": "al-5",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"title": "",
"album": "",
"name": "The Graveyard and the Ballroom",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00",
"year": 1994
},
{
"id": "al-6",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"title": "",
"album": "",
"name": "To Each...",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 1981
},
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-4",
"artist": "Anikas",
"title": "",
"album": "",
"name": "Anika",
"songCount": 9,
"duration": 2169,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 2010
},
{
"id": "al-21",
"coverArt": "al-21",
"artistId": "ar-7",
"artist": "Captain Beefheart & His Magic Band",
"title": "",
"album": "",
"name": "Lick My Decals Off, Baby",
"songCount": 15,
"duration": 2324,
"created": "2019-06-10T19:26:30.944742894+01:00",
"year": 1970
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Snake Charmer",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00",
"year": 1983
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-16",
"coverArt": "al-16",
"artistId": "ar-5",
"artist": "Swell Maps",
"id": "al-4",
"coverArt": "al-4",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Jane From Occupied Europe",
"songCount": 16,
"duration": 3040,
"created": "2019-04-30T16:48:48+01:00",
"year": 1980
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-17",
"coverArt": "al-17",
"artistId": "ar-5",
"artist": "Swell Maps",
"id": "al-7",
"coverArt": "al-7",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "A Trip to Marineville",
"songCount": 18,
"duration": 3266,
"created": "2019-04-30T16:48:48+01:00",
"year": 1979
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-19",
"coverArt": "al-19",
"artistId": "ar-6",
"artist": "Ten Years After",
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Ten Years After",
"songCount": 15,
"duration": 3812,
"created": "2019-04-30T16:48:30+01:00",
"year": 1967
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-11",
"coverArt": "al-11",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-12",
"coverArt": "al-12",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
}
]
}

View File

@@ -6,133 +6,121 @@
"albumList2": {
"album": [
{
"id": "al-17",
"coverArt": "al-17",
"artistId": "ar-5",
"artist": "Swell Maps",
"id": "al-2",
"coverArt": "al-2",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "A Trip to Marineville",
"songCount": 18,
"duration": 3266,
"created": "2019-04-30T16:48:48+01:00",
"year": 1979
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-4",
"artist": "Anikas",
"id": "al-7",
"coverArt": "al-7",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Anika",
"songCount": 9,
"duration": 2169,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 2010
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-8",
"coverArt": "al-8",
"id": "al-11",
"coverArt": "al-11",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Easter Everywhere",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00",
"year": 1967
},
{
"id": "al-16",
"coverArt": "al-16",
"artistId": "ar-5",
"artist": "Swell Maps",
"title": "",
"album": "",
"name": "Jane From Occupied Europe",
"songCount": 16,
"duration": 3040,
"created": "2019-04-30T16:48:48+01:00",
"year": 1980
},
{
"id": "al-21",
"coverArt": "al-21",
"artistId": "ar-7",
"artist": "Captain Beefheart & His Magic Band",
"title": "",
"album": "",
"name": "Lick My Decals Off, Baby",
"songCount": 15,
"duration": 2324,
"created": "2019-06-10T19:26:30.944742894+01:00",
"year": 1970
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Snake Charmer",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00",
"year": 1983
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-19",
"coverArt": "al-19",
"artistId": "ar-6",
"artist": "Ten Years After",
"title": "",
"album": "",
"name": "Ten Years After",
"songCount": 15,
"duration": 3812,
"created": "2019-04-30T16:48:30+01:00",
"year": 1967
},
{
"id": "al-5",
"coverArt": "al-5",
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "The Graveyard and the Ballroom",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00",
"year": 1994
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-12",
"coverArt": "al-12",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-4",
"coverArt": "al-4",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00",
"year": 1966
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-6",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "To Each...",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 1981
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
}
]
}

View File

@@ -6,133 +6,121 @@
"albumList2": {
"album": [
{
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"id": "al-2",
"coverArt": "al-2",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Easter Everywhere",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00",
"year": 1967
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"title": "",
"album": "",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00",
"year": 1966
},
{
"id": "al-21",
"coverArt": "al-21",
"artistId": "ar-7",
"artist": "Captain Beefheart & His Magic Band",
"title": "",
"album": "",
"name": "Lick My Decals Off, Baby",
"songCount": 15,
"duration": 2324,
"created": "2019-06-10T19:26:30.944742894+01:00",
"year": 1970
},
{
"id": "al-5",
"coverArt": "al-5",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"title": "",
"album": "",
"name": "The Graveyard and the Ballroom",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00",
"year": 1994
},
{
"id": "al-6",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"title": "",
"album": "",
"name": "To Each...",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 1981
},
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-4",
"artist": "Anikas",
"title": "",
"album": "",
"name": "Anika",
"songCount": 9,
"duration": 2169,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 2010
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Snake Charmer",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00",
"year": 1983
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-16",
"coverArt": "al-16",
"artistId": "ar-5",
"artist": "Swell Maps",
"id": "al-4",
"coverArt": "al-4",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Jane From Occupied Europe",
"songCount": 16,
"duration": 3040,
"created": "2019-04-30T16:48:48+01:00",
"year": 1980
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-17",
"coverArt": "al-17",
"artistId": "ar-5",
"artist": "Swell Maps",
"id": "al-7",
"coverArt": "al-7",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "A Trip to Marineville",
"songCount": 18,
"duration": 3266,
"created": "2019-04-30T16:48:48+01:00",
"year": 1979
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-19",
"coverArt": "al-19",
"artistId": "ar-6",
"artist": "Ten Years After",
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Ten Years After",
"songCount": 15,
"duration": 3812,
"created": "2019-04-30T16:48:30+01:00",
"year": 1967
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-11",
"coverArt": "al-11",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-12",
"coverArt": "al-12",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
}
]
}

View File

@@ -6,133 +6,121 @@
"albumList2": {
"album": [
{
"id": "al-17",
"coverArt": "al-17",
"artistId": "ar-5",
"artist": "Swell Maps",
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "A Trip to Marineville",
"songCount": 18,
"duration": 3266,
"created": "2019-04-30T16:48:48+01:00",
"year": 1979
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-11",
"coverArt": "al-11",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-4",
"coverArt": "al-4",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"title": "",
"album": "",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00",
"year": 1966
},
{
"id": "al-16",
"coverArt": "al-16",
"artistId": "ar-5",
"artist": "Swell Maps",
"title": "",
"album": "",
"name": "Jane From Occupied Europe",
"songCount": 16,
"duration": 3040,
"created": "2019-04-30T16:48:48+01:00",
"year": 1980
},
{
"id": "al-19",
"coverArt": "al-19",
"artistId": "ar-6",
"artist": "Ten Years After",
"title": "",
"album": "",
"name": "Ten Years After",
"songCount": 15,
"duration": 3812,
"created": "2019-04-30T16:48:30+01:00",
"year": 1967
},
{
"id": "al-6",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "To Each...",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 1981
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"title": "",
"album": "",
"name": "Easter Everywhere",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00",
"year": 1967
},
{
"id": "al-21",
"coverArt": "al-21",
"artistId": "ar-7",
"artist": "Captain Beefheart & His Magic Band",
"title": "",
"album": "",
"name": "Lick My Decals Off, Baby",
"songCount": 15,
"duration": 2324,
"created": "2019-06-10T19:26:30.944742894+01:00",
"year": 1970
},
{
"id": "al-5",
"coverArt": "al-5",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"title": "",
"album": "",
"name": "The Graveyard and the Ballroom",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00",
"year": 1994
},
{
"id": "al-3",
"coverArt": "al-3",
"id": "al-2",
"coverArt": "al-2",
"artistId": "ar-1",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Snake Charmer",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00",
"year": 1983
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-4",
"artist": "Anikas",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Anika",
"songCount": 9,
"duration": 2169,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 2010
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-7",
"coverArt": "al-7",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-12",
"coverArt": "al-12",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
}
]
}

View File

@@ -7,129 +7,80 @@
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Snake Charmer",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00",
"year": 1983,
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021,
"song": [
{
"id": "tr-1",
"album": "Snake Charmer",
"id": "tr-4",
"album": "album-1",
"albumId": "al-3",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"artistId": "ar-1",
"bitRate": 882,
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.978045401+01:00",
"duration": 372,
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/01.05 Snake Charmer.flac",
"size": 41274185,
"path": "artist-0/album-1/track-0.flac",
"suffix": "flac",
"title": "Snake Charmer",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 1983
},
{
"id": "tr-3",
"album": "Snake Charmer",
"albumId": "al-3",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artistId": "ar-1",
"bitRate": 814,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.981605306+01:00",
"duration": 523,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/02.05 Hold On to Your Dreams.flac",
"size": 53447545,
"suffix": "flac",
"title": "Hold On to Your Dreams",
"track": 2,
"discNumber": 1,
"type": "music",
"year": 1983
},
{
"id": "tr-2",
"album": "Snake Charmer",
"albumId": "al-3",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artistId": "ar-1",
"bitRate": 745,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.979981084+01:00",
"duration": 331,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/03.05 It Was a Camel.flac",
"size": 31080508,
"suffix": "flac",
"title": "It Was a Camel",
"track": 3,
"discNumber": 1,
"type": "music",
"year": 1983
"year": 2021
},
{
"id": "tr-5",
"album": "Snake Charmer",
"album": "album-1",
"albumId": "al-3",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"artistId": "ar-1",
"bitRate": 976,
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.984853203+01:00",
"duration": 227,
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/04.05 Sleazy.flac",
"size": 27938750,
"path": "artist-0/album-1/track-1.flac",
"suffix": "flac",
"title": "Sleazy",
"track": 4,
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 1983
"year": 2021
},
{
"id": "tr-4",
"album": "Snake Charmer",
"id": "tr-6",
"album": "album-1",
"albumId": "al-3",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"artistId": "ar-1",
"bitRate": 884,
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.983301328+01:00",
"duration": 418,
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/05.05 Snake Charmer (reprise).flac",
"size": 46427922,
"path": "artist-0/album-1/track-2.flac",
"suffix": "flac",
"title": "Snake Charmer (reprise)",
"track": 5,
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 1983
"year": 2021
}
]
}

View File

@@ -5,12 +5,84 @@
"type": "gonic",
"album": {
"id": "al-2",
"coverArt": "al-2",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "",
"songCount": 0,
"duration": 0,
"created": "2019-05-16T22:10:21+01:00"
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021,
"song": [
{
"id": "tr-1",
"album": "album-0",
"albumId": "al-2",
"artist": "artist-0",
"artistId": "ar-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-2",
"album": "album-0",
"albumId": "al-2",
"artist": "artist-0",
"artistId": "ar-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-3",
"album": "album-0",
"albumId": "al-2",
"artist": "artist-0",
"artistId": "ar-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
}
]
}
}
}

View File

@@ -5,21 +5,47 @@
"type": "gonic",
"artist": {
"id": "ar-1",
"name": "Jah Wobble, The Edge & Holger Czukay",
"albumCount": 1,
"name": "artist-0",
"albumCount": 3,
"album": [
{
"id": "al-2",
"coverArt": "al-2",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Snake Charmer",
"songCount": 5,
"duration": 1871,
"created": "2019-05-16T22:10:52+01:00",
"year": 1983
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-4",
"coverArt": "al-4",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
}
]
}

View File

@@ -5,34 +5,47 @@
"type": "gonic",
"artist": {
"id": "ar-3",
"name": "13th Floor Elevators",
"albumCount": 2,
"name": "artist-2",
"albumCount": 3,
"album": [
{
"id": "al-8",
"coverArt": "al-8",
"id": "al-11",
"coverArt": "al-11",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "Easter Everywhere",
"songCount": 10,
"duration": 2609,
"created": "2019-06-13T12:57:28.850090338+01:00",
"year": 1967
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-9",
"coverArt": "al-9",
"id": "al-12",
"coverArt": "al-12",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"songCount": 21,
"duration": 4222,
"created": "2019-06-13T12:57:24.306717554+01:00",
"year": 1966
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
}
]
}

View File

@@ -5,33 +5,47 @@
"type": "gonic",
"artist": {
"id": "ar-2",
"name": "A Certain Ratio",
"albumCount": 2,
"name": "artist-1",
"albumCount": 3,
"album": [
{
"id": "al-5",
"coverArt": "al-5",
"id": "al-7",
"coverArt": "al-7",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "The Graveyard and the Ballroom",
"songCount": 14,
"duration": 2738,
"created": "2019-06-05T17:46:37.675917974+01:00",
"year": 1994
"name": "album-0",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-6",
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-2",
"artist": "A Certain Ratio",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "To Each...",
"songCount": 9,
"duration": 2801,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 1981
"name": "album-1",
"songCount": 3,
"duration": 300,
"year": 2021
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 3,
"duration": 300,
"year": 2021
}
]
}

View File

@@ -6,69 +6,12 @@
"artists": {
"ignoredArticles": "",
"index": [
{
"name": "#",
"artist": [
{
"id": "ar-3",
"name": "13th Floor Elevators",
"albumCount": 2
}
]
},
{
"name": "a",
"artist": [
{
"id": "ar-2",
"name": "A Certain Ratio",
"albumCount": 2
},
{
"id": "ar-4",
"name": "Anikas",
"albumCount": 1
}
]
},
{
"name": "c",
"artist": [
{
"id": "ar-7",
"name": "Captain Beefheart & His Magic Band",
"albumCount": 1
}
]
},
{
"name": "j",
"artist": [
{
"id": "ar-1",
"name": "Jah Wobble, The Edge & Holger Czukay",
"albumCount": 1
}
]
},
{
"name": "s",
"artist": [
{
"id": "ar-5",
"name": "Swell Maps",
"albumCount": 2
}
]
},
{
"name": "t",
"artist": [
{
"id": "ar-6",
"name": "Ten Years After",
"albumCount": 1
}
{ "id": "ar-1", "name": "artist-0", "albumCount": 3 },
{ "id": "ar-2", "name": "artist-1", "albumCount": 3 },
{ "id": "ar-3", "name": "artist-2", "albumCount": 3 }
]
}
]

View File

@@ -7,69 +7,12 @@
"lastModified": 0,
"ignoredArticles": "",
"index": [
{
"name": "#",
"artist": [
{
"id": "al-7",
"name": "13th Floor Lowervators",
"albumCount": 2
},
{
"id": "al-10",
"name": "___Anika",
"albumCount": 2
}
]
},
{
"name": "a",
"artist": [
{
"id": "al-4",
"name": "A Certain Ratio",
"albumCount": 2
}
]
},
{
"name": "c",
"artist": [
{
"id": "al-20",
"name": "Captain Beefheart",
"albumCount": 1
}
]
},
{
"name": "j",
"artist": [
{
"id": "al-2",
"name": "Jah Wobble, The Edge, Holger Czukay",
"albumCount": 1
}
]
},
{
"name": "s",
"artist": [
{
"id": "al-15",
"name": "Swell Maps",
"albumCount": 2
}
]
},
{
"name": "t",
"artist": [
{
"id": "al-18",
"name": "Ten Years After",
"albumCount": 1
}
{ "id": "al-2", "name": "album-0", "albumCount": 0 },
{ "id": "al-3", "name": "album-1", "albumCount": 0 },
{ "id": "al-4", "name": "album-2", "albumCount": 0 }
]
}
]

View File

@@ -5,106 +5,62 @@
"type": "gonic",
"directory": {
"id": "al-3",
"parent": "al-2",
"name": "(1983) Snake Charmer",
"name": "album-1",
"child": [
{
"id": "tr-1",
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 882,
"id": "tr-4",
"album": "album-1",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.978045401+01:00",
"duration": 372,
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/01.05 Snake Charmer.flac",
"size": 41274185,
"path": "artist-0/album-1/track-0.flac",
"suffix": "flac",
"title": "Snake Charmer",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-3",
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 814,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.981605306+01:00",
"duration": 523,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/02.05 Hold On to Your Dreams.flac",
"size": 53447545,
"suffix": "flac",
"title": "Hold On to Your Dreams",
"track": 2,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-2",
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 745,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.979981084+01:00",
"duration": 331,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/03.05 It Was a Camel.flac",
"size": 31080508,
"suffix": "flac",
"title": "It Was a Camel",
"track": 3,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-5",
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 976,
"album": "album-1",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.984853203+01:00",
"duration": 227,
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/04.05 Sleazy.flac",
"size": 27938750,
"path": "artist-0/album-1/track-1.flac",
"suffix": "flac",
"title": "Sleazy",
"track": 4,
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-4",
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 884,
"id": "tr-6",
"album": "album-1",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-07-08T21:49:40.983301328+01:00",
"duration": 418,
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/05.05 Snake Charmer (reprise).flac",
"size": 46427922,
"path": "artist-0/album-1/track-2.flac",
"suffix": "flac",
"title": "Snake Charmer (reprise)",
"track": 5,
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music"
}

View File

@@ -5,16 +5,64 @@
"type": "gonic",
"directory": {
"id": "al-2",
"name": "Jah Wobble, The Edge, Holger Czukay",
"name": "album-0",
"child": [
{
"id": "al-3",
"coverArt": "al-3",
"created": "2019-05-16T22:10:52+01:00",
"isDir": true,
"id": "tr-1",
"album": "album-0",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"title": "(1983) Snake Charmer"
"path": "artist-0/album-0/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-2",
"album": "album-0",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-3",
"album": "album-0",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music"
}
]
}

View File

@@ -1,31 +0,0 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult3": {
"artist": [
{
"id": "ar-3",
"name": "13th Floor Elevators",
"albumCount": 0
}
],
"album": [
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-3",
"artist": "13th Floor Elevators",
"title": "",
"album": "",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"songCount": 0,
"duration": 0,
"created": "2019-06-13T12:57:24.306717554+01:00",
"year": 1966
}
]
}
}
}

View File

@@ -0,0 +1,128 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult3": {
"album": [
{
"id": "al-2",
"coverArt": "al-2",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 0,
"duration": 0,
"year": 2021
},
{
"id": "al-3",
"coverArt": "al-3",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 0,
"duration": 0,
"year": 2021
},
{
"id": "al-4",
"coverArt": "al-4",
"artistId": "ar-1",
"artist": "artist-0",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 0,
"duration": 0,
"year": 2021
},
{
"id": "al-7",
"coverArt": "al-7",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 0,
"duration": 0,
"year": 2021
},
{
"id": "al-8",
"coverArt": "al-8",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 0,
"duration": 0,
"year": 2021
},
{
"id": "al-9",
"coverArt": "al-9",
"artistId": "ar-2",
"artist": "artist-1",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 0,
"duration": 0,
"year": 2021
},
{
"id": "al-11",
"coverArt": "al-11",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-0",
"songCount": 0,
"duration": 0,
"year": 2021
},
{
"id": "al-12",
"coverArt": "al-12",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-1",
"songCount": 0,
"duration": 0,
"year": 2021
},
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-3",
"artist": "artist-2",
"created": "2019-11-30T00:00:00Z",
"title": "",
"album": "",
"name": "album-2",
"songCount": 0,
"duration": 0,
"year": 2021
}
]
}
}
}

View File

@@ -1,31 +0,0 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult3": {
"artist": [
{
"id": "ar-4",
"name": "Anikas",
"albumCount": 0
}
],
"album": [
{
"id": "al-13",
"coverArt": "al-13",
"artistId": "ar-4",
"artist": "Anikas",
"title": "",
"album": "",
"name": "Anika",
"songCount": 0,
"duration": 0,
"created": "2019-05-23T15:12:02.921473302+01:00",
"year": 2010
}
]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult3": {
"artist": [
{ "id": "ar-1", "name": "artist-0", "albumCount": 0 },
{ "id": "ar-2", "name": "artist-1", "albumCount": 0 },
{ "id": "ar-3", "name": "artist-2", "albumCount": 0 }
]
}
}
}

View File

@@ -1,16 +0,0 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult3": {
"artist": [
{
"id": "ar-2",
"name": "A Certain Ratio",
"albumCount": 0
}
]
}
}
}

View File

@@ -0,0 +1,431 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult3": {
"song": [
{
"id": "tr-1",
"album": "album-0",
"albumId": "al-2",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-2",
"album": "album-0",
"albumId": "al-2",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-3",
"album": "album-0",
"albumId": "al-2",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-4",
"album": "album-1",
"albumId": "al-3",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "artist-0/album-1/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-5",
"album": "album-1",
"albumId": "al-3",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "artist-0/album-1/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-6",
"album": "album-1",
"albumId": "al-3",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "artist-0/album-1/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-7",
"album": "album-2",
"albumId": "al-4",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-4",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-4",
"path": "artist-0/album-2/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-8",
"album": "album-2",
"albumId": "al-4",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-4",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-4",
"path": "artist-0/album-2/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-9",
"album": "album-2",
"albumId": "al-4",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-4",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-4",
"path": "artist-0/album-2/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-10",
"album": "album-0",
"albumId": "al-7",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-7",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-7",
"path": "artist-1/album-0/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-11",
"album": "album-0",
"albumId": "al-7",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-7",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-7",
"path": "artist-1/album-0/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-12",
"album": "album-0",
"albumId": "al-7",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-7",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-7",
"path": "artist-1/album-0/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-13",
"album": "album-1",
"albumId": "al-8",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-8",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-8",
"path": "artist-1/album-1/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-14",
"album": "album-1",
"albumId": "al-8",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-8",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-8",
"path": "artist-1/album-1/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-15",
"album": "album-1",
"albumId": "al-8",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-8",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-8",
"path": "artist-1/album-1/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-16",
"album": "album-2",
"albumId": "al-9",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-9",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-9",
"path": "artist-1/album-2/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-17",
"album": "album-2",
"albumId": "al-9",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-9",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-9",
"path": "artist-1/album-2/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-18",
"album": "album-2",
"albumId": "al-9",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-9",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-9",
"path": "artist-1/album-2/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-19",
"album": "album-0",
"albumId": "al-11",
"artist": "artist-2",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-11",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-11",
"path": "artist-2/album-0/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
},
{
"id": "tr-20",
"album": "album-0",
"albumId": "al-11",
"artist": "artist-2",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-11",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-11",
"path": "artist-2/album-0/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music",
"year": 2021
}
]
}
}
}

View File

@@ -1,148 +0,0 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult2": {
"artist": [
{
"id": "al-7",
"name": "13th Floor Lowervators"
}
],
"album": [
{
"id": "al-9",
"coverArt": "al-9",
"created": "2019-06-13T12:57:24.306717554+01:00",
"isDir": true,
"isVideo": false,
"parent": "al-7",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators"
}
],
"song": [
{
"id": "tr-6",
"album": "(1994) The Graveyard and the Ballroom",
"artist": "A Certain Ratio",
"bitRate": 894,
"contentType": "audio/x-flac",
"coverArt": "al-5",
"created": "2019-07-08T21:49:41.037683099+01:00",
"duration": 332,
"isDir": false,
"isVideo": false,
"parent": "al-5",
"path": "A Certain Ratio/(1994) The Graveyard and the Ballroom/13.14 Flight.flac",
"size": 37302417,
"suffix": "flac",
"title": "Flight",
"track": 13,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-40",
"album": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"artist": "13th Floor Elevators",
"bitRate": 244,
"contentType": "audio/mpeg",
"coverArt": "al-9",
"created": "2019-07-08T21:49:41.209108272+01:00",
"duration": 154,
"isDir": false,
"isVideo": false,
"parent": "al-9",
"path": "13th Floor Lowervators/(1966) The Psychedelic Sounds of the 13th Floor Elevators/13.21 Before You Accuse Me.mp3",
"size": 4722688,
"suffix": "mp3",
"title": "Before You Accuse Me",
"track": 13,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-76",
"album": "(1980) Jane From Occupied Europe",
"artist": "Swell Maps",
"bitRate": 1204,
"contentType": "audio/x-flac",
"coverArt": "al-16",
"created": "2019-07-08T21:49:41.43457798+01:00",
"duration": 220,
"isDir": false,
"isVideo": false,
"parent": "al-16",
"path": "Swell Maps/(1980) Jane From Occupied Europe/13.16 Blenheim Shots.flac",
"size": 33140852,
"suffix": "flac",
"title": "Blenheim Shots",
"track": 13,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-93",
"album": "(1979) A Trip to Marineville",
"artist": "Swell Maps",
"bitRate": 295,
"contentType": "audio/mpeg",
"coverArt": "al-17",
"created": "2019-07-08T21:49:41.493347193+01:00",
"duration": 463,
"isDir": false,
"isVideo": false,
"parent": "al-17",
"path": "Swell Maps/(1979) A Trip to Marineville/d01 13.14 Adventuring Into Basketry.mp3",
"size": 17119309,
"suffix": "mp3",
"title": "Adventuring Into Basketry",
"track": 13,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-107",
"album": "(1967) Ten Years After",
"artist": "Ten Years After",
"bitRate": 192,
"contentType": "audio/ogg",
"coverArt": "al-19",
"created": "2019-07-08T21:49:41.573811068+01:00",
"duration": 433,
"isDir": false,
"isVideo": false,
"parent": "al-19",
"path": "Ten Years After/(1967) Ten Years After/13.15 Spider in My Web.ogg",
"size": 10400948,
"suffix": "ogg",
"title": "Spider in My Web",
"track": 13,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-129",
"album": "(1970) Lick My Decals Off, Bitch",
"artist": "Captain Beefheart & His Magic Band",
"bitRate": 160,
"contentType": "audio/mpeg",
"coverArt": "al-21",
"created": "2019-07-08T21:49:41.687805489+01:00",
"duration": 152,
"isDir": false,
"isVideo": false,
"parent": "al-21",
"path": "Captain Beefheart/(1970) Lick My Decals Off, Bitch/13.15 Space-Age Couple.mp3",
"size": 3054515,
"suffix": "mp3",
"title": "Space-Age Couple",
"track": 13,
"discNumber": 1,
"type": "music"
}
]
}
}
}

View File

@@ -0,0 +1,97 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult2": {
"artist": [
{ "id": "al-2", "name": "album-0" },
{ "id": "al-3", "name": "album-1" },
{ "id": "al-4", "name": "album-2" }
],
"album": [
{
"id": "al-2",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-1",
"title": "album-0"
},
{
"id": "al-3",
"coverArt": "al-3",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-1",
"title": "album-1"
},
{
"id": "al-4",
"coverArt": "al-4",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-1",
"title": "album-2"
},
{
"id": "al-7",
"coverArt": "al-7",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-6",
"title": "album-0"
},
{
"id": "al-8",
"coverArt": "al-8",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-6",
"title": "album-1"
},
{
"id": "al-9",
"coverArt": "al-9",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-6",
"title": "album-2"
},
{
"id": "al-11",
"coverArt": "al-11",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-10",
"title": "album-0"
},
{
"id": "al-12",
"coverArt": "al-12",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-10",
"title": "album-1"
},
{
"id": "al-13",
"coverArt": "al-13",
"created": "2019-11-30T00:00:00Z",
"isDir": true,
"isVideo": false,
"parent": "al-10",
"title": "album-2"
}
]
}
}
}

View File

@@ -1,26 +0,0 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult2": {
"artist": [
{
"id": "al-10",
"name": "___Anika"
}
],
"album": [
{
"id": "al-13",
"coverArt": "al-13",
"created": "2019-05-23T15:12:02.921473302+01:00",
"isDir": true,
"isVideo": false,
"parent": "al-12",
"title": "(2010) Anika"
}
]
}
}
}

View File

@@ -0,0 +1,8 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult2": {}
}
}

View File

@@ -1,15 +0,0 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult2": {
"artist": [
{
"id": "al-4",
"name": "A Certain Ratio"
}
]
}
}
}

View File

@@ -0,0 +1,391 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"type": "gonic",
"searchResult2": {
"song": [
{
"id": "tr-1",
"album": "album-0",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-2",
"album": "album-0",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-3",
"album": "album-0",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-2",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-2",
"path": "artist-0/album-0/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-4",
"album": "album-1",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "artist-0/album-1/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-5",
"album": "album-1",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "artist-0/album-1/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-6",
"album": "album-1",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-3",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-3",
"path": "artist-0/album-1/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-7",
"album": "album-2",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-4",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-4",
"path": "artist-0/album-2/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-8",
"album": "album-2",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-4",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-4",
"path": "artist-0/album-2/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-9",
"album": "album-2",
"artist": "artist-0",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-4",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-4",
"path": "artist-0/album-2/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-10",
"album": "album-0",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-7",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-7",
"path": "artist-1/album-0/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-11",
"album": "album-0",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-7",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-7",
"path": "artist-1/album-0/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-12",
"album": "album-0",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-7",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-7",
"path": "artist-1/album-0/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-13",
"album": "album-1",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-8",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-8",
"path": "artist-1/album-1/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-14",
"album": "album-1",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-8",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-8",
"path": "artist-1/album-1/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-15",
"album": "album-1",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-8",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-8",
"path": "artist-1/album-1/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-16",
"album": "album-2",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-9",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-9",
"path": "artist-1/album-2/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-17",
"album": "album-2",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-9",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-9",
"path": "artist-1/album-2/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-18",
"album": "album-2",
"artist": "artist-1",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-9",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-9",
"path": "artist-1/album-2/track-2.flac",
"suffix": "flac",
"title": "title-2",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-19",
"album": "album-0",
"artist": "artist-2",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-11",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-11",
"path": "artist-2/album-0/track-0.flac",
"suffix": "flac",
"title": "title-0",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"id": "tr-20",
"album": "album-0",
"artist": "artist-2",
"bitRate": 100,
"contentType": "audio/x-flac",
"coverArt": "al-11",
"created": "2019-11-30T00:00:00Z",
"duration": 100,
"isDir": false,
"isVideo": false,
"parent": "al-11",
"path": "artist-2/album-0/track-1.flac",
"suffix": "flac",
"title": "title-1",
"track": 1,
"discNumber": 1,
"type": "music"
}
]
}
}
}

View File

@@ -1,39 +1,19 @@
package db
import (
"errors"
"fmt"
"log"
"net/url"
"os"
"strings"
"github.com/gorilla/securecookie"
"github.com/jinzhu/gorm"
"gopkg.in/gormigrate.v1"
)
// wrapMigrations wraps a list of migrations to add logging and transactions
func wrapMigrations(migrs ...gormigrate.Migration) []*gormigrate.Migration {
log := func(i int, mig gormigrate.MigrateFunc, name string) gormigrate.MigrateFunc {
return func(db *gorm.DB) error {
// print that we're on the ith out of n migrations
defer log.Printf("migration (%d/%d) '%s' finished", i+1, len(migrs), name)
return db.Transaction(mig)
}
}
ret := make([]*gormigrate.Migration, 0, len(migrs))
for i, mig := range migrs {
ret = append(ret, &gormigrate.Migration{
ID: mig.ID,
Rollback: mig.Rollback,
Migrate: log(i, mig.Migrate, mig.ID),
})
}
return ret
}
func defaultOptions() url.Values {
func DefaultOptions() url.Values {
return url.Values{
// with this, multiple connections share a single data and schema cache.
// see https://www.sqlite.org/sharedcache.html
@@ -46,17 +26,23 @@ func defaultOptions() url.Values {
}
}
func mockOptions() url.Values {
return url.Values{
"_foreign_keys": {"true"},
}
}
type DB struct {
*gorm.DB
}
func New(path string) (*DB, error) {
func New(path string, options url.Values) (*DB, error) {
// https://github.com/mattn/go-sqlite3#connection-string
url := url.URL{
Scheme: "file",
Opaque: path,
}
url.RawQuery = defaultOptions().Encode()
url.RawQuery = options.Encode()
db, err := gorm.Open("sqlite3", url.String())
if err != nil {
return nil, fmt.Errorf("with gorm: %w", err)
@@ -91,34 +77,29 @@ func New(path string) (*DB, error) {
}
func NewMock() (*DB, error) {
return New(":memory:")
return New(":memory:", mockOptions())
}
func (db *DB) GetSetting(key string) string {
func (db *DB) GetSetting(key string) (string, error) {
setting := &Setting{}
db.
Where("key=?", key).
First(setting)
return setting.Value
if err := db.Where("key=?", key).First(setting).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
return setting.Value, nil
}
func (db *DB) SetSetting(key, value string) {
db.
func (db *DB) SetSetting(key, value string) error {
return db.
Where(Setting{Key: key}).
Assign(Setting{Value: value}).
FirstOrCreate(&Setting{})
}
func (db *DB) GetOrCreateKey(key string) string {
value := db.GetSetting(key)
if value == "" {
value = string(securecookie.GenerateRandomKey(32))
db.SetSetting(key, value)
}
return value
FirstOrCreate(&Setting{}).
Error
}
func (db *DB) InsertBulkLeftMany(table string, head []string, left int, col []int) error {
if len(col) == 0 {
return nil
}
var rows []string
var values []interface{}
for _, c := range col {
@@ -139,7 +120,7 @@ func (db *DB) GetUserByID(id int) *User {
Where("id=?", id).
First(user).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return user
@@ -151,7 +132,7 @@ func (db *DB) GetUserByName(name string) *User {
Where("name=?", name).
First(user).
Error
if gorm.IsRecordNotFoundError(err) {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return user
@@ -164,6 +145,9 @@ func (db *DB) Begin() *DB {
type ChunkFunc func(*gorm.DB, []int64) error
func (db *DB) TransactionChunked(data []int64, cb ChunkFunc) error {
if len(data) == 0 {
return nil
}
// https://sqlite.org/limits.html
const size = 999
return db.Transaction(func(tx *gorm.DB) error {

View File

@@ -1,16 +1,16 @@
package db
import (
"io/ioutil"
"log"
"math/rand"
"os"
"testing"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/matryer/is"
)
var testDB *DB
func randKey() string {
letters := []rune("abcdef0123456789")
b := make([]rune, 16)
@@ -22,27 +22,31 @@ func randKey() string {
func TestGetSetting(t *testing.T) {
key := randKey()
// new key
expected := "hello"
testDB.SetSetting(key, expected)
actual := testDB.GetSetting(key)
if actual != expected {
t.Errorf("expected %q, got %q", expected, actual)
value := "howdy"
is := is.New(t)
testDB, err := NewMock()
if err != nil {
t.Fatalf("error creating db: %v", err)
}
// existing key
expected = "howdy"
testDB.SetSetting(key, expected)
actual = testDB.GetSetting(key)
if actual != expected {
t.Errorf("expected %q, got %q", expected, actual)
if err := testDB.Migrate(MigrationContext{}); err != nil {
t.Fatalf("error migrating db: %v", err)
}
is.NoErr(testDB.SetSetting(key, value))
actual, err := testDB.GetSetting(key)
is.NoErr(err)
is.Equal(actual, value)
is.NoErr(testDB.SetSetting(key, value))
actual, err = testDB.GetSetting(key)
is.NoErr(err)
is.Equal(actual, value)
}
func TestMain(m *testing.M) {
var err error
testDB, err = NewMock()
if err != nil {
log.Fatalf("error opening database: %v\n", err)
}
log.SetOutput(ioutil.Discard)
os.Exit(m.Run())
}

View File

@@ -1,6 +1,7 @@
package db
import (
"errors"
"fmt"
"github.com/jinzhu/gorm"
@@ -31,20 +32,14 @@ func migrateInitSchema() gormigrate.Migration {
}
}
func migrateCreateInitUser() gormigrate.Migration {
return gormigrate.Migration{
ID: "202002192019",
Migrate: func(tx *gorm.DB) error {
const (
initUsername = "admin"
initPassword = "admin"
)
err := tx.
Where("name=?", initUsername).
First(&User{}).
Error
if !gorm.IsRecordNotFoundError(err) {
return nil
func construct(ctx MigrationContext, id string, f func(*gorm.DB, MigrationContext) error) *gormigrate.Migration {
return &gormigrate.Migration{
ID: id,
Migrate: func(db *gorm.DB) error {
tx := db.Begin()
defer tx.Commit()
if err := f(tx, ctx); err != nil {
return fmt.Errorf("%q: %w", id, err)
}
return tx.Create(&User{

View File

@@ -88,7 +88,7 @@ type Track struct {
Artist *Artist
ArtistID int `gorm:"not null" sql:"default: null; type:int REFERENCES artists(id) ON DELETE CASCADE"`
Genres []*Genre `gorm:"many2many:track_genres"`
Size int `gorm:"not null" sql:"default: null"`
Size int `sql:"default: null"`
Length int `sql:"default: null"`
Bitrate int `sql:"default: null"`
TagTitle string `sql:"default: null"`

269
server/mockfs/mockfs.go Normal file
View File

@@ -0,0 +1,269 @@
package mockfs
import (
"errors"
"fmt"
"io/fs"
"io/ioutil"
"os"
"path/filepath"
"testing"
"time"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/server/scanner/tags"
)
var ErrPathNotFound = errors.New("path not found")
type MockFS struct {
t *testing.T
scanner *scanner.Scanner
dir string
reader *mreader
db *db.DB
}
func New(t *testing.T) *MockFS {
return new(t, []string{""})
}
func NewWithDirs(t *testing.T, dirs []string) *MockFS {
return new(t, dirs)
}
func new(t *testing.T, dirs []string) *MockFS {
dbc, err := db.NewMock()
if err != nil {
t.Fatalf("create db: %v", err)
}
if err := dbc.Migrate(db.MigrationContext{}); err != nil {
t.Fatalf("migrate db db: %v", err)
}
dbc.LogMode(false)
tmpDir, err := ioutil.TempDir("", "gonic-test-")
if err != nil {
t.Fatalf("create tmp dir: %v", err)
}
var absDirs []string
for _, dir := range dirs {
absDirs = append(absDirs, filepath.Join(tmpDir, dir))
}
parser := &mreader{map[string]*Tags{}}
scanner := scanner.New(absDirs, true, dbc, ";", parser)
return &MockFS{
t: t,
scanner: scanner,
dir: tmpDir,
reader: parser,
db: dbc,
}
}
func (m *MockFS) DB() *db.DB { return m.db }
func (m *MockFS) TmpDir() string { return m.dir }
func (m *MockFS) ScanAndClean() {
if err := m.scanner.ScanAndClean(scanner.ScanOptions{}); err != nil {
m.t.Fatalf("error scan and cleaning: %v", err)
}
}
func (m *MockFS) ResetDates() {
t := time.Date(2020, 0, 0, 0, 0, 0, 0, time.UTC)
if err := m.db.Model(db.Album{}).Updates(db.Album{CreatedAt: t, UpdatedAt: t, ModifiedAt: t}).Error; err != nil {
m.t.Fatalf("reset album times: %v", err)
}
if err := m.db.Model(db.Track{}).Updates(db.Track{CreatedAt: t, UpdatedAt: t}).Error; err != nil {
m.t.Fatalf("reset track times: %v", err)
}
}
func (m *MockFS) CleanUp() {
if err := m.db.Close(); err != nil {
m.t.Fatalf("close db: %v", err)
}
if err := os.RemoveAll(m.dir); err != nil {
m.t.Fatalf("remove all: %v", err)
}
}
func (m *MockFS) addItems(prefix string, covers bool) {
p := func(format string, a ...interface{}) string {
return filepath.Join(prefix, fmt.Sprintf(format, a...))
}
for ar := 0; ar < 3; ar++ {
for al := 0; al < 3; al++ {
for tr := 0; tr < 3; tr++ {
m.AddTrack(p("artist-%d/album-%d/track-%d.flac", ar, al, tr))
m.SetTags(p("artist-%d/album-%d/track-%d.flac", ar, al, tr), func(tags *Tags) {
tags.RawArtist = fmt.Sprintf("artist-%d", ar)
tags.RawAlbumArtist = fmt.Sprintf("artist-%d", ar)
tags.RawAlbum = fmt.Sprintf("album-%d", al)
tags.RawTitle = fmt.Sprintf("title-%d", tr)
})
}
if covers {
m.AddCover(p("artist-%d/album-%d/cover.png", ar, al))
}
}
}
}
func (m *MockFS) AddItems() { m.addItems("", false) }
func (m *MockFS) AddItemsPrefix(prefix string) { m.addItems(prefix, false) }
func (m *MockFS) AddItemsWithCovers() { m.addItems("", true) }
func (m *MockFS) AddItemsPrefixWithCovers(prefix string) { m.addItems(prefix, true) }
func (m *MockFS) RemoveAll(path string) {
abspath := filepath.Join(m.dir, path)
if err := os.RemoveAll(abspath); err != nil {
m.t.Fatalf("remove all: %v", err)
}
}
func (m *MockFS) LogItems() {
var dirs int
err := filepath.Walk(m.dir, func(path string, info fs.FileInfo, err error) error {
m.t.Logf("item %q", path)
if info.IsDir() {
dirs++
}
return nil
})
if err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("dirs: %d", dirs)
}
func (m *MockFS) LogAlbums() {
var albums []*db.Album
if err := m.db.Find(&albums).Error; err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("\nalbums")
for _, album := range albums {
m.t.Logf("id %-3d root %-3s %-10s %-10s pid %-3d aid %-3d cov %-10s",
album.ID, album.RootDir, album.LeftPath, album.RightPath, album.ParentID, album.TagArtistID, album.Cover)
}
m.t.Logf("total %d", len(albums))
}
func (m *MockFS) LogArtists() {
var artists []*db.Artist
if err := m.db.Find(&artists).Error; err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("\nartists")
for _, artist := range artists {
m.t.Logf("id %-3d %-10s", artist.ID, artist.Name)
}
m.t.Logf("total %d", len(artists))
}
func (m *MockFS) LogTracks() {
var tracks []*db.Track
if err := m.db.Find(&tracks).Error; err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("\ntracks")
for _, track := range tracks {
m.t.Logf("id %-3d aid %-3d filename %-10s tagtitle %-10s",
track.ID, track.AlbumID, track.Filename, track.TagTitle)
}
}
func (m *MockFS) LogTrackGenres() {
var tgs []*db.TrackGenre
if err := m.db.Find(&tgs).Error; err != nil {
m.t.Fatalf("error logging items: %v", err)
}
m.t.Logf("\ntrack genres")
for _, tg := range tgs {
m.t.Logf("tid %-3d gid %-3d", tg.TrackID, tg.GenreID)
}
}
func (m *MockFS) AddTrack(path string) {
abspath := filepath.Join(m.dir, path)
dir := filepath.Dir(abspath)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
m.t.Fatalf("mkdir: %v", err)
}
if _, err := os.Create(abspath); err != nil {
m.t.Fatalf("create track: %v", err)
}
}
func (m *MockFS) AddCover(path string) {
abspath := filepath.Join(m.dir, path)
if err := os.MkdirAll(filepath.Dir(abspath), os.ModePerm); err != nil {
m.t.Fatalf("mkdir: %v", err)
}
if _, err := os.Create(abspath); err != nil {
m.t.Fatalf("create cover: %v", err)
}
}
func (m *MockFS) SetTags(path string, cb func(*Tags)) {
abspath := filepath.Join(m.dir, path)
if err := os.Chtimes(abspath, time.Time{}, time.Now()); err != nil {
m.t.Fatalf("touch track: %v", err)
}
if _, ok := m.reader.tags[abspath]; !ok {
m.reader.tags[abspath] = &Tags{}
}
cb(m.reader.tags[abspath])
}
type mreader struct {
tags map[string]*Tags
}
func (m *mreader) Read(abspath string) (tags.Parser, error) {
parser, ok := m.tags[abspath]
if !ok {
return nil, ErrPathNotFound
}
return parser, nil
}
var _ tags.Reader = (*mreader)(nil)
type Tags struct {
RawTitle string
RawArtist string
RawAlbum string
RawAlbumArtist string
RawGenre string
}
func (m *Tags) Title() string { return m.RawTitle }
func (m *Tags) BrainzID() string { return "" }
func (m *Tags) Artist() string { return m.RawArtist }
func (m *Tags) Album() string { return m.RawAlbum }
func (m *Tags) AlbumArtist() string { return m.RawAlbumArtist }
func (m *Tags) AlbumBrainzID() string { return "" }
func (m *Tags) Genre() string { return m.RawGenre }
func (m *Tags) TrackNumber() int { return 1 }
func (m *Tags) DiscNumber() int { return 1 }
func (m *Tags) Length() int { return 100 }
func (m *Tags) Bitrate() int { return 100 }
func (m *Tags) Year() int { return 2021 }
func (m *Tags) SomeAlbum() string { return m.Album() }
func (m *Tags) SomeArtist() string { return m.Artist() }
func (m *Tags) SomeAlbumArtist() string { return m.AlbumArtist() }
func (m *Tags) SomeGenre() string { return m.Genre() }
var _ tags.Parser = (*Tags)(nil)

View File

@@ -24,16 +24,25 @@ import (
"go.senan.xyz/gonic/server/scanner/tags"
)
const DownloadAllWaitInterval = 3 * time.Second
const downloadAllWaitInterval = 3 * time.Second
type Podcasts struct {
DB *db.DB
PodcastBasePath string
db *db.DB
baseDir string
tagger tags.Reader
}
func New(db *db.DB, base string, tagger tags.Reader) *Podcasts {
return &Podcasts{
db: db,
baseDir: base,
tagger: tagger,
}
}
func (p *Podcasts) GetPodcastOrAll(userID int, id int, includeEpisodes bool) ([]*db.Podcast, error) {
podcasts := []*db.Podcast{}
q := p.DB.Where("user_id=?", userID)
q := p.db.Where("user_id=?", userID)
if id != 0 {
q = q.Where("id=?", id)
}
@@ -55,7 +64,7 @@ func (p *Podcasts) GetPodcastOrAll(userID int, id int, includeEpisodes bool) ([]
func (p *Podcasts) GetPodcastEpisodes(podcastID int) ([]*db.PodcastEpisode, error) {
episodes := []*db.PodcastEpisode{}
err := p.DB.
err := p.db.
Where("podcast_id=?", podcastID).
Order("publish_date DESC").
Find(&episodes).
@@ -75,12 +84,12 @@ func (p *Podcasts) AddNewPodcast(rssURL string, feed *gofeed.Feed,
Title: feed.Title,
URL: rssURL,
}
podPath := podcast.Fullpath(p.PodcastBasePath)
podPath := podcast.Fullpath(p.baseDir)
err := os.Mkdir(podPath, 0755)
if err != nil && !os.IsExist(err) {
return nil, err
}
if err := p.DB.Save(&podcast).Error; err != nil {
if err := p.db.Save(&podcast).Error; err != nil {
return &podcast, err
}
if err := p.AddNewEpisodes(&podcast, feed.Items); err != nil {
@@ -96,7 +105,7 @@ func (p *Podcasts) AddNewPodcast(rssURL string, feed *gofeed.Feed,
func (p *Podcasts) SetAutoDownload(podcastID int, setting db.PodcastAutoDownload) error {
podcast := db.Podcast{}
err := p.DB.
err := p.db.
Where("id=?", podcastID).
First(&podcast).
Error
@@ -104,7 +113,7 @@ func (p *Podcasts) SetAutoDownload(podcastID int, setting db.PodcastAutoDownload
return err
}
podcast.AutoDownload = setting
if err := p.DB.Save(&podcast).Error; err != nil {
if err := p.db.Save(&podcast).Error; err != nil {
return fmt.Errorf("save setting: %w", err)
}
return nil
@@ -123,7 +132,7 @@ func getEntriesAfterDate(feed []*gofeed.Item, after time.Time) []*gofeed.Item {
func (p *Podcasts) AddNewEpisodes(podcast *db.Podcast, items []*gofeed.Item) error {
podcastEpisode := db.PodcastEpisode{}
err := p.DB.
err := p.db.
Where("podcast_id=?", podcast.ID).
Order("publish_date DESC").
First(&podcastEpisode).Error
@@ -192,13 +201,13 @@ func (p *Podcasts) AddEpisode(podcastID int, item *gofeed.Item) (*db.PodcastEpis
}
if episode, ok := p.findEnclosureAudio(podcastID, duration, item); ok {
if err := p.DB.Save(episode).Error; err != nil {
if err := p.db.Save(episode).Error; err != nil {
return nil, err
}
return episode, nil
}
if episode, ok := p.findMediaAudio(podcastID, duration, item); ok {
if err := p.DB.Save(episode).Error; err != nil {
if err := p.db.Save(episode).Error; err != nil {
return nil, err
}
return episode, nil
@@ -259,7 +268,7 @@ func (p *Podcasts) findMediaAudio(podcastID, duration int,
func (p *Podcasts) RefreshPodcasts() error {
podcasts := []*db.Podcast{}
if err := p.DB.Find(&podcasts).Error; err != nil {
if err := p.db.Find(&podcasts).Error; err != nil {
return fmt.Errorf("find podcasts: %w", err)
}
var errs *multierr.Err
@@ -271,7 +280,7 @@ func (p *Podcasts) RefreshPodcasts() error {
func (p *Podcasts) RefreshPodcastsForUser(userID int) error {
podcasts := []*db.Podcast{}
err := p.DB.
err := p.db.
Where("user_id=?", userID).
Find(&podcasts).
Error
@@ -304,7 +313,7 @@ func (p *Podcasts) refreshPodcasts(podcasts []*db.Podcast) error {
func (p *Podcasts) DownloadPodcastAll(podcastID int) error {
podcastEpisodes := []db.PodcastEpisode{}
err := p.DB.
err := p.db.
Where("podcast_id=?", podcastID).
Find(&podcastEpisodes).
Error
@@ -322,7 +331,7 @@ func (p *Podcasts) DownloadPodcastAll(podcastID int) error {
continue
}
log.Printf("finished downloading episode: %q", episode.Title)
time.Sleep(DownloadAllWaitInterval)
time.Sleep(downloadAllWaitInterval)
}
}()
return nil
@@ -331,14 +340,14 @@ func (p *Podcasts) DownloadPodcastAll(podcastID int) error {
func (p *Podcasts) DownloadEpisode(episodeID int) error {
podcastEpisode := db.PodcastEpisode{}
podcast := db.Podcast{}
err := p.DB.
err := p.db.
Where("id=?", episodeID).
First(&podcastEpisode).
Error
if err != nil {
return fmt.Errorf("get podcast episode by id: %w", err)
}
err = p.DB.
err = p.db.
Where("id=?", podcastEpisode.PodcastID).
First(&podcast).
Error
@@ -350,7 +359,7 @@ func (p *Podcasts) DownloadEpisode(episodeID int) error {
return nil
}
podcastEpisode.Status = db.PodcastEpisodeStatusDownloading
p.DB.Save(&podcastEpisode)
p.db.Save(&podcastEpisode)
// nolint: bodyclose
resp, err := http.Get(podcastEpisode.AudioURL)
if err != nil {
@@ -365,14 +374,14 @@ func (p *Podcasts) DownloadEpisode(episodeID int) error {
filename = path.Base(audioURL.Path)
}
filename = p.findUniqueEpisodeName(&podcast, &podcastEpisode, filename)
audioFile, err := os.Create(path.Join(podcast.Fullpath(p.PodcastBasePath), filename))
audioFile, err := os.Create(path.Join(podcast.Fullpath(p.baseDir), filename))
if err != nil {
return fmt.Errorf("create audio file: %w", err)
}
podcastEpisode.Filename = filename
sanTitle := strings.ReplaceAll(podcast.Title, "/", "_")
podcastEpisode.Path = path.Join(sanTitle, filename)
p.DB.Save(&podcastEpisode)
p.db.Save(&podcastEpisode)
go func() {
if err := p.doPodcastDownload(&podcastEpisode, audioFile, resp.Body); err != nil {
log.Printf("error downloading podcast: %v", err)
@@ -385,18 +394,18 @@ func (p *Podcasts) findUniqueEpisodeName(
podcast *db.Podcast,
podcastEpisode *db.PodcastEpisode,
filename string) string {
podcastPath := path.Join(podcast.Fullpath(p.PodcastBasePath), filename)
podcastPath := path.Join(podcast.Fullpath(p.baseDir), filename)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return filename
}
sanitizedTitle := strings.ReplaceAll(podcastEpisode.Title, "/", "_")
titlePath := fmt.Sprintf("%s%s", sanitizedTitle, filepath.Ext(filename))
podcastPath = path.Join(podcast.Fullpath(p.PodcastBasePath), titlePath)
podcastPath = path.Join(podcast.Fullpath(p.baseDir), titlePath)
if _, err := os.Stat(podcastPath); os.IsNotExist(err) {
return titlePath
}
// try to find a filename like FILENAME (1).mp3 incrementing
return findEpisode(podcast.Fullpath(p.PodcastBasePath), filename, 1)
return findEpisode(podcast.Fullpath(p.baseDir), filename, 1)
}
func findEpisode(base, filename string, count int) string {
@@ -442,7 +451,7 @@ func (p *Podcasts) downloadPodcastCover(podPath string, podcast *db.Podcast) err
podcastPath := filepath.Clean(strings.ReplaceAll(podcast.Title, "/", "_"))
podcastFilename := fmt.Sprintf("cover%s", ext)
podcast.ImagePath = path.Join(podcastPath, podcastFilename)
if err := p.DB.Save(podcast).Error; err != nil {
if err := p.db.Save(podcast).Error; err != nil {
return fmt.Errorf("save podcast: %w", err)
}
return nil
@@ -454,24 +463,24 @@ func (p *Podcasts) doPodcastDownload(podcastEpisode *db.PodcastEpisode, file *os
}
defer file.Close()
stat, _ := file.Stat()
podcastPath := path.Join(p.PodcastBasePath, podcastEpisode.Path)
podcastTags, err := tags.New(podcastPath)
podcastPath := path.Join(p.baseDir, podcastEpisode.Path)
podcastTags, err := p.tagger.Read(podcastPath)
if err != nil {
log.Printf("error parsing podcast audio: %e", err)
podcastEpisode.Status = db.PodcastEpisodeStatusError
p.DB.Save(podcastEpisode)
p.db.Save(podcastEpisode)
return nil
}
podcastEpisode.Bitrate = podcastTags.Bitrate()
podcastEpisode.Status = db.PodcastEpisodeStatusCompleted
podcastEpisode.Length = podcastTags.Length()
podcastEpisode.Size = int(stat.Size())
return p.DB.Save(podcastEpisode).Error
return p.db.Save(podcastEpisode).Error
}
func (p *Podcasts) DeletePodcast(userID, podcastID int) error {
podcast := db.Podcast{}
err := p.DB.
err := p.db.
Where("id=? AND user_id=?", podcastID, userID).
First(&podcast).
Error
@@ -479,17 +488,17 @@ func (p *Podcasts) DeletePodcast(userID, podcastID int) error {
return err
}
var userCount int
p.DB.
p.db.
Model(&db.Podcast{}).
Where("title=?", podcast.Title).
Count(&userCount)
if userCount == 1 {
// only delete the folder if there are not multiple listeners
if err = os.RemoveAll(podcast.Fullpath(p.PodcastBasePath)); err != nil {
if err = os.RemoveAll(podcast.Fullpath(p.baseDir)); err != nil {
return fmt.Errorf("delete podcast directory: %w", err)
}
}
err = p.DB.
err = p.db.
Where("id=? AND user_id=?", podcastID, userID).
Delete(db.Podcast{}).
Error
@@ -501,13 +510,13 @@ func (p *Podcasts) DeletePodcast(userID, podcastID int) error {
func (p *Podcasts) DeletePodcastEpisode(podcastEpisodeID int) error {
episode := db.PodcastEpisode{}
err := p.DB.First(&episode, podcastEpisodeID).Error
err := p.db.First(&episode, podcastEpisodeID).Error
if err != nil {
return err
}
episode.Status = db.PodcastEpisodeStatusDeleted
p.DB.Save(&episode)
if err := os.Remove(filepath.Join(p.PodcastBasePath, episode.Path)); err != nil {
p.db.Save(&episode)
if err := os.Remove(filepath.Join(p.baseDir, episode.Path)); err != nil {
return err
}
return err

View File

@@ -5,8 +5,8 @@ import (
"fmt"
"log"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"sync/atomic"
@@ -16,9 +16,9 @@ import (
"github.com/karrick/godirwalk"
"github.com/rainycape/unidecode"
"go.senan.xyz/gonic/multierr"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/mime"
"go.senan.xyz/gonic/server/scanner/stack"
"go.senan.xyz/gonic/server/scanner/tags"
)
@@ -28,70 +28,347 @@ var (
ErrReadingTags = errors.New("could not read tags")
)
func durSince(t time.Time) time.Duration {
return time.Since(t).Truncate(10 * time.Microsecond)
}
// decoded converts a string to it's latin equivalent.
// it will be used by the model's *UDec fields, and is only set if it
// differs from the original. the fields are used for searching.
func decoded(in string) string {
if u := unidecode.Unidecode(in); u != in {
return u
}
return ""
}
// isScanning acts as an atomic boolean semaphore. we don't
// want to have more than one scan going on at a time
var isScanning int32 //nolint:gochecknoglobals
func IsScanning() bool {
return atomic.LoadInt32(&isScanning) == 1
}
func SetScanning() func() {
atomic.StoreInt32(&isScanning, 1)
return func() {
atomic.StoreInt32(&isScanning, 0)
}
}
type Scanner struct {
db *db.DB
musicPath string
isFull bool
musicPaths []string
sorted bool
genreSplit string
// these two are for the transaction we do for every album.
// the boolean is there so we dont begin or commit multiple
// times in the handle album or post children callback
trTx *db.DB
trTxOpen bool
// these two are for keeping state between noted in the tree.
// eg. keep track of a parents album or the path to a cover
// we just saw that we need to commit in the post children
// callback
curAlbums *stack.Stack
curCover string
// then the rest are for stats and cleanup at the very end
seenTracks map[int]struct{} // set of p keys
seenAlbums map[int]struct{} // set of p keys
seenTracksNew int // n tracks not seen before
tagger tags.Reader
scanning *int32
}
func New(musicPath string, db *db.DB, genreSplit string) *Scanner {
func New(musicPaths []string, sorted bool, db *db.DB, genreSplit string, tagger tags.Reader) *Scanner {
return &Scanner{
db: db,
musicPath: musicPath,
musicPaths: musicPaths,
sorted: sorted,
genreSplit: genreSplit,
tagger: tagger,
scanning: new(int32),
}
}
// ## begin clean funcs
// ## begin clean funcs
// ## begin clean funcs
type ScanOptions struct {
IsFull bool
// TODO https://github.com/sentriz/gonic/issues/64
Path string
}
func (s *Scanner) cleanTracks() error {
func (s *Scanner) IsScanning() bool {
return atomic.LoadInt32(s.scanning) == 1
}
type ScanOptions struct {
IsFull bool
}
func (s *Scanner) ScanAndClean(opts ScanOptions) error {
c := &collected{
seenTracks: map[int]struct{}{},
seenAlbums: map[int]struct{}{},
}
if err := s.scan(c, opts.IsFull); err != nil {
return err
}
if err := s.clean(c); err != nil {
return err
}
return nil
}
func (s *Scanner) scan(c *collected, isFull bool) error {
if s.IsScanning() {
return ErrAlreadyScanning
}
atomic.StoreInt32(s.scanning, 1)
defer atomic.StoreInt32(s.scanning, 0)
start := time.Now()
itemErrs := multierr.Err{}
log.Println("starting scan")
defer func() {
log.Printf("finished scan in %s, +%d/%d tracks (%d err)\n",
durSince(start), c.seenTracksNew, len(c.seenTracks), itemErrs.Len())
}()
for _, musicPath := range s.musicPaths {
err := godirwalk.Walk(musicPath, &godirwalk.Options{
Callback: func(_ string, _ *godirwalk.Dirent) error {
return nil
},
PostChildrenCallback: func(itemPath string, _ *godirwalk.Dirent) error {
return s.callback(c, isFull, musicPath, itemPath)
},
Unsorted: !s.sorted,
FollowSymbolicLinks: true,
ErrorCallback: func(path string, err error) godirwalk.ErrorAction {
itemErrs.Add(fmt.Errorf("%q: %w", path, err))
return godirwalk.SkipNode
},
})
if err != nil {
return fmt.Errorf("walking filesystem: %w", err)
}
}
if err := s.db.SetSetting("last_scan_time", strconv.FormatInt(time.Now().Unix(), 10)); err != nil {
return fmt.Errorf("set scan time: %w", err)
}
if itemErrs.Len() > 0 {
return itemErrs
}
return nil
}
func (s *Scanner) clean(c *collected) error {
if err := s.cleanTracks(c.seenTracks); err != nil {
return fmt.Errorf("clean tracks: %w", err)
}
if err := s.cleanAlbums(c.seenAlbums); err != nil {
return fmt.Errorf("clean albums: %w", err)
}
if err := s.cleanArtists(); err != nil {
return fmt.Errorf("clean artists: %w", err)
}
if err := s.cleanGenres(); err != nil {
return fmt.Errorf("clean genres: %w", err)
}
return nil
}
func (s *Scanner) callback(c *collected, isFull bool, rootAbsPath string, itemAbsPath string) error {
if rootAbsPath == itemAbsPath {
return nil
}
relpath, _ := filepath.Rel(rootAbsPath, itemAbsPath)
gs, err := godirwalk.NewScanner(itemAbsPath)
if err != nil {
return err
}
var tracks []string
var cover string
for gs.Scan() {
if isCover(gs.Name()) {
cover = gs.Name()
continue
}
if _, ok := mime.FromExtension(ext(gs.Name())); ok {
tracks = append(tracks, gs.Name())
continue
}
}
tx := s.db.Begin()
defer tx.Commit()
pdir, pbasename := filepath.Split(filepath.Dir(relpath))
parent := &db.Album{}
if err := tx.Where(db.Album{RootDir: rootAbsPath, LeftPath: pdir, RightPath: pbasename}).FirstOrCreate(parent).Error; err != nil {
return fmt.Errorf("first or create parent: %w", err)
}
c.seenAlbums[parent.ID] = struct{}{}
dir, basename := filepath.Split(relpath)
album := &db.Album{}
if err := tx.Where(db.Album{RootDir: rootAbsPath, LeftPath: dir, RightPath: basename}).First(album).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("find album: %w", err)
}
if err := populateAlbumBasics(tx, rootAbsPath, parent, album, dir, basename, cover); err != nil {
return fmt.Errorf("populate album basics: %w", err)
}
c.seenAlbums[album.ID] = struct{}{}
sort.Strings(tracks)
for i, basename := range tracks {
abspath := filepath.Join(itemAbsPath, basename)
if err := s.populateTrackAndAlbumArtists(tx, c, i, album, basename, abspath, isFull); err != nil {
return fmt.Errorf("process %q: %w", "", err)
}
}
return nil
}
func (s *Scanner) populateTrackAndAlbumArtists(tx *db.DB, c *collected, i int, album *db.Album, basename string, abspath string, isFull bool) error {
track := &db.Track{AlbumID: album.ID, Filename: filepath.Base(basename)}
if err := tx.Where(track).First(track).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("query track: %w", err)
}
c.seenTracks[track.ID] = struct{}{}
stat, err := os.Stat(abspath)
if err != nil {
return fmt.Errorf("stating %q: %w", basename, err)
}
if !isFull && stat.ModTime().Before(track.UpdatedAt) {
return nil
}
trags, err := s.tagger.Read(abspath)
if err != nil {
return fmt.Errorf("%v: %w", err, ErrReadingTags)
}
artistName := trags.SomeAlbumArtist()
albumArtist, err := s.populateAlbumArtist(tx, artistName)
if err != nil {
return fmt.Errorf("populate artist: %w", err)
}
if err := populateTrack(tx, album, albumArtist, track, trags, basename, int(stat.Size())); err != nil {
return fmt.Errorf("process %q: %w", basename, err)
}
c.seenTracks[track.ID] = struct{}{}
c.seenTracksNew++
genreNames := strings.Split(trags.SomeGenre(), s.genreSplit)
genreIDs, err := s.populateGenres(tx, track, genreNames)
if err != nil {
return fmt.Errorf("populate genres: %w", err)
}
if err := s.populateTrackGenres(tx, track, genreIDs); err != nil {
return fmt.Errorf("propulate track genres: %w", err)
}
// metadata for the album table comes only from the the first track's tags
if i > 0 {
return nil
}
if err := populateAlbum(tx, album, albumArtist, trags, stat.ModTime()); err != nil {
return fmt.Errorf("propulate album: %w", err)
}
if err := populateAlbumGenres(tx, album, genreIDs); err != nil {
return fmt.Errorf("populate album genres: %w", err)
}
return nil
}
func populateAlbum(tx *db.DB, album *db.Album, albumArtist *db.Artist, trags tags.Parser, modTime time.Time) error {
albumName := trags.SomeAlbum()
album.TagTitle = albumName
album.TagTitleUDec = decoded(albumName)
album.TagBrainzID = trags.AlbumBrainzID()
album.TagYear = trags.Year()
album.TagArtistID = albumArtist.ID
album.ModifiedAt = modTime
if err := tx.Save(&album).Error; err != nil {
return fmt.Errorf("saving album: %w", err)
}
return nil
}
func populateAlbumBasics(tx *db.DB, rootAbsPath string, parent, album *db.Album, dir, basename string, cover string) error {
album.RootDir = rootAbsPath
album.LeftPath = dir
album.RightPath = basename
album.Cover = cover
album.RightPathUDec = decoded(basename)
album.ParentID = parent.ID
if err := tx.Save(&album).Error; err != nil {
return fmt.Errorf("saving album: %w", err)
}
return nil
}
func populateTrack(tx *db.DB, album *db.Album, albumArtist *db.Artist, track *db.Track, trags tags.Parser, abspath string, size int) error {
basename := filepath.Base(abspath)
track.Filename = basename
track.FilenameUDec = decoded(basename)
track.Size = size
track.AlbumID = album.ID
track.ArtistID = albumArtist.ID
track.TagTitle = trags.Title()
track.TagTitleUDec = decoded(trags.Title())
track.TagTrackArtist = trags.Artist()
track.TagTrackNumber = trags.TrackNumber()
track.TagDiscNumber = trags.DiscNumber()
track.TagBrainzID = trags.BrainzID()
track.Length = trags.Length() // these two should be calculated
track.Bitrate = trags.Bitrate() // ...from the file instead of tags
if err := tx.Save(&track).Error; err != nil {
return fmt.Errorf("saving track: %w", err)
}
return nil
}
func (s *Scanner) populateAlbumArtist(tx *db.DB, artistName string) (*db.Artist, error) {
var artist db.Artist
update := db.Artist{
Name: artistName,
NameUDec: decoded(artistName),
}
if err := tx.Where("name=?", artistName).Assign(update).FirstOrCreate(&artist).Error; err != nil {
return nil, fmt.Errorf("find or create artist: %w", err)
}
return &artist, nil
}
func (s *Scanner) populateGenres(tx *db.DB, track *db.Track, names []string) ([]int, error) {
var filteredNames []string
for _, name := range names {
if clean := strings.TrimSpace(name); clean != "" {
filteredNames = append(filteredNames, clean)
}
}
if len(filteredNames) == 0 {
return []int{}, nil
}
var ids []int
for _, name := range filteredNames {
var genre db.Genre
if err := tx.FirstOrCreate(&genre, db.Genre{Name: name}).Error; err != nil {
return nil, fmt.Errorf("find or create genre: %w", err)
}
ids = append(ids, genre.ID)
}
return ids, nil
}
func (s *Scanner) populateTrackGenres(tx *db.DB, track *db.Track, genreIDs []int) error {
if err := tx.Where("track_id=?", track.ID).Delete(db.TrackGenre{}).Error; err != nil {
return fmt.Errorf("delete old track genre records: %w", err)
}
if err := tx.InsertBulkLeftMany("track_genres", []string{"track_id", "genre_id"}, track.ID, genreIDs); err != nil {
return fmt.Errorf("insert bulk track genres: %w", err)
}
return nil
}
func populateAlbumGenres(tx *db.DB, album *db.Album, genreIDs []int) error {
if err := tx.Where("album_id=?", album.ID).Delete(db.AlbumGenre{}).Error; err != nil {
return fmt.Errorf("delete old album genre records: %w", err)
}
if err := tx.InsertBulkLeftMany("album_genres", []string{"album_id", "genre_id"}, album.ID, genreIDs); err != nil {
return fmt.Errorf("insert bulk album genres: %w", err)
}
return nil
}
func (s *Scanner) cleanTracks(seenTracks map[int]struct{}) error {
start := time.Now()
var previous []int
var missing []int64
@@ -103,7 +380,7 @@ func (s *Scanner) cleanTracks() error {
return fmt.Errorf("plucking ids: %w", err)
}
for _, prev := range previous {
if _, ok := s.seenTracks[prev]; !ok {
if _, ok := seenTracks[prev]; !ok {
missing = append(missing, int64(prev))
}
}
@@ -117,7 +394,7 @@ func (s *Scanner) cleanTracks() error {
return nil
}
func (s *Scanner) cleanAlbums() error {
func (s *Scanner) cleanAlbums(seenAlbums map[int]struct{}) error {
start := time.Now()
var previous []int
var missing []int64
@@ -129,7 +406,7 @@ func (s *Scanner) cleanAlbums() error {
return fmt.Errorf("plucking ids: %w", err)
}
for _, prev := range previous {
if _, ok := s.seenAlbums[prev]; !ok {
if _, ok := seenAlbums[prev]; !ok {
missing = append(missing, int64(prev))
}
}
@@ -176,383 +453,50 @@ func (s *Scanner) cleanGenres() error {
Where("album_genres.genre_id IS NULL").
SubQuery()
q := s.db.
Where("genres.id IN ?", subTrack).
Or("genres.id IN ?", subAlbum).
Where("genres.id IN ? AND genres.id IN ?", subTrack, subAlbum).
Delete(&db.Genre{})
log.Printf("finished clean genres in %s, %d removed", durSince(start), q.RowsAffected)
return nil
}
// ## begin entries
// ## begin entries
// ## begin entries
type ScanOptions struct {
IsFull bool
// TODO https://github.com/sentriz/gonic/issues/64
Path string
func ext(name string) string {
ext := filepath.Ext(name)
if len(ext) == 0 {
return ""
}
return ext[1:]
}
func (s *Scanner) Start(opts ScanOptions) error {
if IsScanning() {
return ErrAlreadyScanning
}
unSet := SetScanning()
defer unSet()
// reset state vars for the new scan
s.isFull = opts.IsFull
s.seenTracks = map[int]struct{}{}
s.seenAlbums = map[int]struct{}{}
s.curAlbums = &stack.Stack{}
s.seenTracksNew = 0
// begin walking
log.Println("starting scan")
var errCount int
start := time.Now()
err := godirwalk.Walk(s.musicPath, &godirwalk.Options{
Callback: s.callbackItem,
PostChildrenCallback: s.callbackPost,
Unsorted: true,
FollowSymbolicLinks: true,
ErrorCallback: func(path string, err error) godirwalk.ErrorAction {
log.Printf("error processing `%s`: %v", path, err)
errCount++
return godirwalk.SkipNode
},
})
if err != nil {
return fmt.Errorf("walking filesystem: %w", err)
}
log.Printf("finished scan in %s, +%d/%d tracks (%d err)\n",
durSince(start),
s.seenTracksNew,
len(s.seenTracks),
errCount,
)
if err := s.cleanTracks(); err != nil {
return fmt.Errorf("clean tracks: %w", err)
}
if err := s.cleanAlbums(); err != nil {
return fmt.Errorf("clean albums: %w", err)
}
if err := s.cleanArtists(); err != nil {
return fmt.Errorf("clean artists: %w", err)
}
if err := s.cleanGenres(); err != nil {
return fmt.Errorf("clean genres: %w", err)
}
// finish up
strNow := strconv.FormatInt(time.Now().Unix(), 10)
s.db.SetSetting("last_scan_time", strNow)
return nil
}
// items are passed to the handle*() functions
type item struct {
fullPath string
relPath string
directory string
filename string
stat os.FileInfo
}
func isCover(filename string) bool {
filename = strings.ToLower(filename)
known := map[string]struct{}{
"cover.png": {},
"cover.jpg": {},
"cover.jpeg": {},
"folder.png": {},
"folder.jpg": {},
"folder.jpeg": {},
"album.png": {},
"album.jpg": {},
"album.jpeg": {},
"albumart.png": {},
"albumart.jpg": {},
"albumart.jpeg": {},
"front.png": {},
"front.jpg": {},
"front.jpeg": {},
}
_, ok := known[filename]
return ok
}
// ## begin callbacks
// ## begin callbacks
// ## begin callbacks
func (s *Scanner) callbackItem(fullPath string, info *godirwalk.Dirent) error {
stat, err := os.Stat(fullPath)
if err != nil {
return fmt.Errorf("%w: %v", ErrStatingItem, err)
}
relPath, err := filepath.Rel(s.musicPath, fullPath)
if err != nil {
return fmt.Errorf("getting relative path: %w", err)
}
directory, filename := path.Split(relPath)
it := &item{
fullPath: fullPath,
relPath: relPath,
directory: directory,
filename: filename,
stat: stat,
}
isDir, err := info.IsDirOrSymlinkToDir()
if err != nil {
return fmt.Errorf("stating link to dir: %w", err)
}
if isDir {
return s.handleAlbum(it)
}
if isCover(filename) {
s.curCover = filename
return nil
}
ext := path.Ext(filename)
if ext == "" {
return nil
}
if _, ok := mime.FromExtension(ext[1:]); ok {
return s.handleTrack(it)
}
return nil
}
func (s *Scanner) callbackPost(fullPath string, info *godirwalk.Dirent) error {
defer func() {
s.curCover = ""
}()
if s.trTxOpen {
s.trTx.Commit()
s.trTxOpen = false
}
// begin taking the current album off the stack and add it's
// parent, cover that we found, etc.
album := s.curAlbums.Pop()
if album.Cover == s.curCover && album.ParentID != 0 {
return nil
}
album.ParentID = s.curAlbums.PeekID()
album.Cover = s.curCover
if err := s.db.Save(album).Error; err != nil {
return fmt.Errorf("writing albums table: %w", err)
}
// we only log changed albums
log.Printf("processed folder `%s`\n",
path.Join(album.LeftPath, album.RightPath))
return nil
}
// ## begin handlers
// ## begin handlers
// ## begin handlers
func (s *Scanner) itemUnchanged(statModTime, updatedInDB time.Time) bool {
if s.isFull {
func isCover(name string) bool {
switch path := strings.ToLower(name); path {
case
"cover.png", "cover.jpg", "cover.jpeg",
"folder.png", "folder.jpg", "folder.jpeg",
"album.png", "album.jpg", "album.jpeg",
"albumart.png", "albumart.jpg", "albumart.jpeg",
"front.png", "front.jpg", "front.jpeg":
return true
default:
return false
}
return statModTime.Before(updatedInDB)
}
func (s *Scanner) handleAlbum(it *item) error {
if s.trTxOpen {
// a transaction still being open when we handle an album can
// happen if there is a album that contains /both/ tracks and
// sub albums
s.trTx.Commit()
s.trTxOpen = false
// decoded converts a string to it's latin equivalent.
// it will be used by the model's *UDec fields, and is only set if it
// differs from the original. the fields are used for searching.
func decoded(in string) string {
if u := unidecode.Unidecode(in); u != in {
return u
}
album := &db.Album{}
defer func() {
// album's id will come from early return
// or save at the end
s.seenAlbums[album.ID] = struct{}{}
s.curAlbums.Push(album)
}()
err := s.db.
Where(db.Album{
LeftPath: it.directory,
RightPath: it.filename,
}).
First(album).
Error
if !gorm.IsRecordNotFoundError(err) &&
s.itemUnchanged(it.stat.ModTime(), album.UpdatedAt) {
// we found the record but it hasn't changed
return nil
}
album.LeftPath = it.directory
album.RightPath = it.filename
album.RightPathUDec = decoded(it.filename)
album.ModifiedAt = it.stat.ModTime()
if err := s.db.Save(album).Error; err != nil {
return fmt.Errorf("writing albums table: %w", err)
}
return nil
return ""
}
func (s *Scanner) handleTrack(it *item) error {
if !s.trTxOpen {
s.trTx = s.db.Begin()
s.trTxOpen = true
}
// init empty track and mark its ID (from lookup or save)
// for later cleanup later
var track db.Track
defer func() {
s.seenTracks[track.ID] = struct{}{}
}()
album := s.curAlbums.Peek()
err := s.trTx.
Select("id, updated_at").
Where(db.Track{
AlbumID: album.ID,
Filename: it.filename,
}).
First(&track).
Error
if !gorm.IsRecordNotFoundError(err) &&
s.itemUnchanged(it.stat.ModTime(), track.UpdatedAt) {
// we found the record but it hasn't changed
return nil
}
trags, err := tags.New(it.fullPath)
if err != nil {
return ErrReadingTags
}
genreIDs, err := s.populateGenres(&track, trags)
if err != nil {
return fmt.Errorf("populate genres: %w", err)
}
// create album and album artist records for first track in album
if album.TagTitle == "" {
albumArtist, err := s.populateAlbumArtist(trags)
if err != nil {
return fmt.Errorf("populate artist: %w", err)
}
albumName := trags.SomeAlbum()
album.TagTitle = albumName
album.TagTitleUDec = decoded(albumName)
album.TagBrainzID = trags.AlbumBrainzID()
album.TagYear = trags.Year()
album.TagArtistID = albumArtist.ID
if err := s.populateAlbumGenres(album, genreIDs); err != nil {
return fmt.Errorf("populate album genres: %w", err)
}
}
track.Filename = it.filename
track.FilenameUDec = decoded(it.filename)
track.Size = int(it.stat.Size())
track.AlbumID = album.ID
track.ArtistID = album.TagArtistID
track.TagTitle = trags.Title()
track.TagTitleUDec = decoded(trags.Title())
track.TagTrackArtist = trags.Artist()
track.TagTrackNumber = trags.TrackNumber()
track.TagDiscNumber = trags.DiscNumber()
track.TagBrainzID = trags.BrainzID()
track.Length = trags.Length() // these two should be calculated
track.Bitrate = trags.Bitrate() // ...from the file instead of tags
if err := s.trTx.Save(&track).Error; err != nil {
return fmt.Errorf("writing track table: %w", err)
}
s.seenTracksNew++
if err := s.populateTrackGenres(&track, genreIDs); err != nil {
return fmt.Errorf("populating track genres : %w", err)
}
return nil
func durSince(t time.Time) time.Duration {
return time.Since(t).Truncate(10 * time.Microsecond)
}
func (s *Scanner) populateAlbumArtist(trags *tags.Tags) (*db.Artist, error) {
var artist db.Artist
artistName := trags.SomeAlbumArtist()
err := s.trTx.
Where("name=?", artistName).
Assign(db.Artist{
Name: artistName,
NameUDec: decoded(artistName),
}).
FirstOrCreate(&artist).
Error
if err != nil {
return nil, fmt.Errorf("find or create artist: %w", err)
}
return &artist, nil
}
func (s *Scanner) populateGenres(track *db.Track, trags *tags.Tags) ([]int, error) {
var genreIDs []int
genreNames := strings.Split(trags.SomeGenre(), s.genreSplit)
for _, genreName := range genreNames {
genre := &db.Genre{}
q := s.trTx.FirstOrCreate(genre, db.Genre{
Name: genreName,
})
if err := q.Error; err != nil {
return nil, err
}
genreIDs = append(genreIDs, genre.ID)
}
return genreIDs, nil
}
func (s *Scanner) populateTrackGenres(track *db.Track, genreIDs []int) error {
err := s.trTx.
Where("track_id=?", track.ID).
Delete(db.TrackGenre{}).
Error
if err != nil {
return fmt.Errorf("delete old track genre records: %w", err)
}
err = s.trTx.InsertBulkLeftMany(
"track_genres",
[]string{"track_id", "genre_id"},
track.ID,
genreIDs,
)
if err != nil {
return fmt.Errorf("insert bulk track genres: %w", err)
}
return nil
}
func (s *Scanner) populateAlbumGenres(album *db.Album, genreIDs []int) error {
err := s.trTx.
Where("album_id=?", album.ID).
Delete(db.AlbumGenre{}).
Error
if err != nil {
return fmt.Errorf("delete old album genre records: %w", err)
}
err = s.trTx.InsertBulkLeftMany(
"album_genres",
[]string{"album_id", "genre_id"},
album.ID,
genreIDs,
)
if err != nil {
return fmt.Errorf("insert bulk album genres: %w", err)
}
return nil
type collected struct {
seenTracks map[int]struct{}
seenAlbums map[int]struct{}
seenTracksNew int
}

View File

@@ -1,4 +1,4 @@
package scanner
package scanner_test
import (
"io/ioutil"
@@ -6,62 +6,319 @@ import (
"os"
"testing"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/matryer/is"
"go.senan.xyz/gonic/server/db"
"go.senan.xyz/gonic/server/mockfs"
)
var testScanner *Scanner
func resetTables(db *db.DB) {
tx := db.Begin()
defer tx.Commit()
tx.Exec("delete from tracks")
tx.Exec("delete from artists")
tx.Exec("delete from albums")
}
func resetTablesPause(db *db.DB, b *testing.B) {
b.StopTimer()
defer b.StartTimer()
resetTables(db)
}
func BenchmarkScanFresh(b *testing.B) {
for n := 0; n < b.N; n++ {
resetTablesPause(testScanner.db, b)
_ = testScanner.Start(ScanOptions{})
}
}
func BenchmarkScanIncremental(b *testing.B) {
// do a full scan and reset
_ = testScanner.Start(ScanOptions{})
b.ResetTimer()
// do the inc scans
for n := 0; n < b.N; n++ {
_ = testScanner.Start(ScanOptions{})
}
}
func TestMain(m *testing.M) {
db, err := db.NewMock()
if err != nil {
log.Fatalf("error opening database: %v\n", err)
}
// benchmarks aren't real code are they? >:)
// here is an absolute path to my music directory
testScanner = New("/home/senan/music", db, "\n")
log.SetOutput(ioutil.Discard)
os.Exit(m.Run())
}
// RESULTS fresh
// 20 times / 1.436
// 20 times / 1.39
func TestTableCounts(t *testing.T) {
t.Parallel()
is := is.NewRelaxed(t)
m := mockfs.New(t)
defer m.CleanUp()
// RESULTS inc
// 100 times / 1.86
// 100 times / 1.9
// 100 times / 1.5
// 100 times / 1.48
m.AddItems()
m.ScanAndClean()
var tracks int
is.NoErr(m.DB().Model(&db.Track{}).Count(&tracks).Error) // not all tracks
is.Equal(tracks, 3*3*3) // not all tracks
var albums int
is.NoErr(m.DB().Model(&db.Album{}).Count(&albums).Error) // not all albums
is.Equal(albums, 13) // not all albums
var artists int
is.NoErr(m.DB().Model(&db.Artist{}).Count(&artists).Error) // not all artists
is.Equal(artists, 3) // not all artists
}
func TestParentID(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
defer m.CleanUp()
m.AddItems()
m.ScanAndClean()
var nullParentAlbums []*db.Album
is.NoErr(m.DB().Where("parent_id IS NULL").Find(&nullParentAlbums).Error) // one parent_id=NULL which is root folder
is.Equal(len(nullParentAlbums), 1) // one parent_id=NULL which is root folder
is.Equal(nullParentAlbums[0].LeftPath, "")
is.Equal(nullParentAlbums[0].RightPath, ".")
is.Equal(m.DB().Where("id=parent_id").Find(&db.Album{}).Error, gorm.ErrRecordNotFound) // no self-referencing albums
var album db.Album
var parent db.Album
is.NoErr(m.DB().Find(&album, "left_path=? AND right_path=?", "artist-0/", "album-0").Error) // album has parent ID
is.NoErr(m.DB().Find(&parent, "right_path=?", "artist-0").Error) // album has parent ID
is.Equal(album.ParentID, parent.ID) // album has parent ID
}
func TestUpdatedCover(t *testing.T) {
t.Parallel()
is := is.NewRelaxed(t)
m := mockfs.New(t)
defer m.CleanUp()
m.AddItems()
m.ScanAndClean()
m.AddCover("artist-0/album-0/cover.jpg")
m.ScanAndClean()
var album db.Album
is.NoErr(m.DB().Where("left_path=? AND right_path=?", "artist-0/", "album-0").Find(&album).Error) // album has cover
is.Equal(album.Cover, "cover.jpg") // album has cover
}
func TestCoverBeforeTracks(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
defer m.CleanUp()
m.AddCover("artist-2/album-2/cover.jpg")
m.ScanAndClean()
m.AddItems()
m.ScanAndClean()
var album db.Album
is.NoErr(m.DB().Preload("TagArtist").Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error) // album has cover
is.Equal(album.Cover, "cover.jpg") // album has cover
is.Equal(album.TagArtist.Name, "artist-2") // album artist
var tracks []*db.Track
is.NoErr(m.DB().Where("album_id=?", album.ID).Find(&tracks).Error) // album has tracks
is.Equal(len(tracks), 3) // album has tracks
}
func TestUpdatedTags(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
defer m.CleanUp()
m.AddTrack("artist-10/album-10/track-10.flac")
m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) {
tags.RawArtist = "artist"
tags.RawAlbumArtist = "album-artist"
tags.RawAlbum = "album"
tags.RawTitle = "title"
})
m.ScanAndClean()
var track db.Track
is.NoErr(m.DB().Preload("Album").Preload("Artist").Where("filename=?", "track-10.flac").Find(&track).Error) // track has tags
is.Equal(track.TagTrackArtist, "artist") // track has tags
is.Equal(track.Artist.Name, "album-artist") // track has tags
is.Equal(track.Album.TagTitle, "album") // track has tags
is.Equal(track.TagTitle, "title") // track has tags
m.SetTags("artist-10/album-10/track-10.flac", func(tags *mockfs.Tags) {
tags.RawArtist = "artist-upd"
tags.RawAlbumArtist = "album-artist-upd"
tags.RawAlbum = "album-upd"
tags.RawTitle = "title-upd"
})
m.ScanAndClean()
var updated db.Track
is.NoErr(m.DB().Preload("Album").Preload("Artist").Where("filename=?", "track-10.flac").Find(&updated).Error) // updated has tags
is.Equal(updated.ID, track.ID) // updated has tags
is.Equal(updated.TagTrackArtist, "artist-upd") // updated has tags
is.Equal(updated.Artist.Name, "album-artist-upd") // updated has tags
is.Equal(updated.Album.TagTitle, "album-upd") // updated has tags
is.Equal(updated.TagTitle, "title-upd") // updated has tags
}
func TestDelete(t *testing.T) {
t.Parallel()
is := is.NewRelaxed(t)
m := mockfs.New(t)
defer m.CleanUp()
m.AddItems()
m.ScanAndClean()
var album db.Album
is.NoErr(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error) // album exists
m.RemoveAll("artist-2/album-2")
m.ScanAndClean()
is.Equal(m.DB().Where("left_path=? AND right_path=?", "artist-2/", "album-2").Find(&album).Error, gorm.ErrRecordNotFound) // album doesn't exist
}
func TestGenres(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
defer m.CleanUp()
albumGenre := func(artist, album, genre string) error {
return m.DB().
Where("albums.left_path=? AND albums.right_path=? AND genres.name=?", artist, album, genre).
Joins("JOIN albums ON albums.id=album_genres.album_id").
Joins("JOIN genres ON genres.id=album_genres.genre_id").
Find(&db.AlbumGenre{}).
Error
}
isAlbumGenre := func(artist, album, genreName string) {
is.Helper()
is.NoErr(albumGenre(artist, album, genreName))
}
isAlbumGenreMissing := func(artist, album, genreName string) {
is.Helper()
is.Equal(albumGenre(artist, album, genreName), gorm.ErrRecordNotFound)
}
trackGenre := func(artist, album, filename, genreName string) error {
return m.DB().
Where("albums.left_path=? AND albums.right_path=? AND tracks.filename=? AND genres.name=?", artist, album, filename, genreName).
Joins("JOIN tracks ON tracks.id=track_genres.track_id").
Joins("JOIN genres ON genres.id=track_genres.genre_id").
Joins("JOIN albums ON albums.id=tracks.album_id").
Find(&db.TrackGenre{}).
Error
}
isTrackGenre := func(artist, album, filename, genreName string) {
is.Helper()
is.NoErr(trackGenre(artist, album, filename, genreName))
}
isTrackGenreMissing := func(artist, album, filename, genreName string) {
is.Helper()
is.Equal(trackGenre(artist, album, filename, genreName), gorm.ErrRecordNotFound)
}
genre := func(genre string) error {
return m.DB().Where("name=?", genre).Find(&db.Genre{}).Error
}
isGenre := func(genreName string) {
is.Helper()
is.NoErr(genre(genreName))
}
isGenreMissing := func(genreName string) {
is.Helper()
is.Equal(genre(genreName), gorm.ErrRecordNotFound)
}
m.AddItems()
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-a;genre-b" })
m.SetTags("artist-0/album-0/track-1.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-c;genre-d" })
m.SetTags("artist-1/album-2/track-0.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-e;genre-f" })
m.SetTags("artist-1/album-2/track-1.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-g;genre-h" })
m.ScanAndClean()
isGenre("genre-a") // genre exists
isGenre("genre-b") // genre exists
isGenre("genre-c") // genre exists
isGenre("genre-d") // genre exists
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-a") // track genre exists
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-b") // track genre exists
isTrackGenre("artist-0/", "album-0", "track-1.flac", "genre-c") // track genre exists
isTrackGenre("artist-0/", "album-0", "track-1.flac", "genre-d") // track genre exists
isTrackGenre("artist-1/", "album-2", "track-0.flac", "genre-e") // track genre exists
isTrackGenre("artist-1/", "album-2", "track-0.flac", "genre-f") // track genre exists
isTrackGenre("artist-1/", "album-2", "track-1.flac", "genre-g") // track genre exists
isTrackGenre("artist-1/", "album-2", "track-1.flac", "genre-h") // track genre exists
isAlbumGenre("artist-0/", "album-0", "genre-a") // album genre exists
isAlbumGenre("artist-0/", "album-0", "genre-b") // album genre exists
m.SetTags("artist-0/album-0/track-0.flac", func(tags *mockfs.Tags) { tags.RawGenre = "genre-aa;genre-bb" })
m.ScanAndClean()
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-aa") // updated track genre exists
isTrackGenre("artist-0/", "album-0", "track-0.flac", "genre-bb") // updated track genre exists
isTrackGenreMissing("artist-0/", "album-0", "track-0.flac", "genre-a") // old track genre missing
isTrackGenreMissing("artist-0/", "album-0", "track-0.flac", "genre-b") // old track genre missing
isAlbumGenreMissing("artist-0/", "album-0", "genre-a") // old album genre missing
isAlbumGenreMissing("artist-0/", "album-0", "genre-b") // old album genre missing
isGenreMissing("genre-a") // old genre missing
isGenreMissing("genre-b") // old genre missing
}
func TestMultiFolders(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.NewWithDirs(t, []string{"m-1", "m-2", "m-3"})
defer m.CleanUp()
m.AddItemsPrefix("m-1")
m.AddItemsPrefix("m-2")
m.AddItemsPrefix("m-3")
m.ScanAndClean()
var rootDirs []*db.Album
is.NoErr(m.DB().Where("parent_id IS NULL").Find(&rootDirs).Error)
is.Equal(len(rootDirs), 3)
for i, r := range rootDirs {
is.Equal(r.RootDir, filepath.Join(m.TmpDir(), fmt.Sprintf("m-%d", i+1)))
is.Equal(r.ParentID, 0)
is.Equal(r.LeftPath, "")
is.Equal(r.RightPath, ".")
}
m.AddCover("m-3/artist-0/album-0/cover.jpg")
m.ScanAndClean()
m.LogItems()
checkCover := func(root string, q string) {
is.Helper()
is.NoErr(m.DB().Where(q, filepath.Join(m.TmpDir(), root)).Find(&db.Album{}).Error)
}
checkCover("m-1", "root_dir=? AND cover IS NULL") // mf 1 no cover
checkCover("m-2", "root_dir=? AND cover IS NULL") // mf 2 no cover
checkCover("m-3", "root_dir=? AND cover='cover.jpg'") // mf 3 has cover
}
func TestNewAlbumForExistingArtist(t *testing.T) {
t.Parallel()
is := is.New(t)
m := mockfs.New(t)
defer m.CleanUp()
m.AddItems()
m.ScanAndClean()
m.LogAlbums()
m.LogArtists()
var artist db.Artist
is.NoErr(m.DB().Where("name=?", "artist-2").Find(&artist).Error) // find orig artist
is.True(artist.ID > 0)
for tr := 0; tr < 3; tr++ {
m.AddTrack(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr))
m.SetTags(fmt.Sprintf("artist-2/new-album/track-%d.mp3", tr), func(tags *mockfs.Tags) {
tags.RawArtist = "artist-2"
tags.RawAlbumArtist = "artist-2"
tags.RawAlbum = "new-album"
tags.RawTitle = fmt.Sprintf("title-%d", tr)
})
}
var updated db.Artist
is.NoErr(m.DB().Where("name=?", "artist-2").Find(&updated).Error) // find updated artist
is.Equal(artist.ID, updated.ID) // find updated artist
var all []*db.Artist
is.NoErr(m.DB().Find(&all).Error) // still only 3?
is.Equal(len(all), 3) // still only 3?
}

View File

@@ -1,61 +0,0 @@
package stack
import (
"fmt"
"strings"
"go.senan.xyz/gonic/server/db"
)
type item struct {
value *db.Album
next *item
}
type Stack struct {
top *item
len uint
}
func (s *Stack) Push(v *db.Album) {
s.top = &item{
value: v,
next: s.top,
}
s.len++
}
func (s *Stack) Pop() *db.Album {
if s.len == 0 {
return nil
}
v := s.top.value
s.top = s.top.next
s.len--
return v
}
func (s *Stack) Peek() *db.Album {
if s.len == 0 {
return nil
}
return s.top.value
}
func (s *Stack) PeekID() int {
if s.len == 0 {
return 0
}
return s.top.value.ID
}
func (s *Stack) String() string {
var str strings.Builder
str.WriteString("[")
for i, f := uint(0), s.top; i < s.len; i++ {
str.WriteString(fmt.Sprintf("%d, ", f.value.ID))
f = f.next
}
str.WriteString("]")
return str.String()
}

View File

@@ -1,36 +0,0 @@
package stack
import (
"testing"
"go.senan.xyz/gonic/server/db"
)
func TestFolderStack(t *testing.T) {
sta := &Stack{}
sta.Push(&db.Album{ID: 3})
sta.Push(&db.Album{ID: 4})
sta.Push(&db.Album{ID: 5})
sta.Push(&db.Album{ID: 6})
expected := "[6, 5, 4, 3, ]"
actual := sta.String()
if expected != actual {
t.Errorf("first stack: expected string "+
"%q, got %q", expected, actual)
}
//
sta = &Stack{}
sta.Push(&db.Album{ID: 27})
sta.Push(&db.Album{ID: 4})
sta.Peek()
sta.Push(&db.Album{ID: 5})
sta.Push(&db.Album{ID: 6})
sta.Push(&db.Album{ID: 7})
sta.Pop()
expected = "[6, 5, 4, 27, ]"
actual = sta.String()
if expected != actual {
t.Errorf("second stack: expected string "+
"%q, got %q", expected, actual)
}
}

View File

@@ -7,6 +7,56 @@ import (
"github.com/nicksellen/audiotags"
)
type TagReader struct{}
func (*TagReader) Read(abspath string) (Parser, error) {
raw, props, err := audiotags.Read(abspath)
return &Tagger{raw, props}, err
}
type Tagger struct {
raw map[string]string
props *audiotags.AudioProperties
}
func (t *Tagger) first(keys ...string) string {
for _, key := range keys {
if val, ok := t.raw[key]; ok {
return val
}
}
return ""
}
func (t *Tagger) Title() string { return t.first("title") }
func (t *Tagger) BrainzID() string { return t.first("musicbrainz_trackid") }
func (t *Tagger) Artist() string { return t.first("artist") }
func (t *Tagger) Album() string { return t.first("album") }
func (t *Tagger) AlbumArtist() string { return t.first("albumartist", "album artist") }
func (t *Tagger) AlbumBrainzID() string { return t.first("musicbrainz_albumid") }
func (t *Tagger) Genre() string { return t.first("genre") }
func (t *Tagger) TrackNumber() int { return intSep(t.first("tracknumber"), "/") } // eg. 5/12
func (t *Tagger) DiscNumber() int { return intSep(t.first("discnumber"), "/") } // eg. 1/2
func (t *Tagger) Length() int { return t.props.Length }
func (t *Tagger) Bitrate() int { return t.props.Bitrate }
func (t *Tagger) Year() int { return intSep(t.first("originaldate", "date", "year"), "-") }
func (t *Tagger) SomeAlbum() string { return first("Unknown Album", t.Album()) }
func (t *Tagger) SomeArtist() string { return first("Unknown Artist", t.Artist()) }
func (t *Tagger) SomeAlbumArtist() string {
return first("Unknown Artist", t.AlbumArtist(), t.Artist())
}
func (t *Tagger) SomeGenre() string { return first("Unknown Genre", t.Genre()) }
func first(or string, strs ...string) string {
for _, str := range strs {
if str != "" {
return str
}
}
return or
}
func intSep(in, sep string) int {
if in == "" {
return 0
@@ -19,48 +69,26 @@ func intSep(in, sep string) int {
return out
}
type Tags struct {
raw map[string]string
props *audiotags.AudioProperties
type Reader interface {
Read(abspath string) (Parser, error)
}
func New(path string) (*Tags, error) {
raw, props, err := audiotags.Read(path)
return &Tags{raw, props}, err
}
func (t *Tags) firstTag(keys ...string) string {
for _, key := range keys {
if val, ok := t.raw[key]; ok {
return val
}
}
return ""
}
func (t *Tags) Title() string { return t.firstTag("title") }
func (t *Tags) BrainzID() string { return t.firstTag("musicbrainz_trackid") }
func (t *Tags) Artist() string { return t.firstTag("artist") }
func (t *Tags) Album() string { return t.firstTag("album") }
func (t *Tags) AlbumArtist() string { return t.firstTag("albumartist", "album artist") }
func (t *Tags) AlbumBrainzID() string { return t.firstTag("musicbrainz_albumid") }
func (t *Tags) Genre() string { return t.firstTag("genre") }
func (t *Tags) TrackNumber() int { return intSep(t.firstTag("tracknumber"), "/") } // eg. 5/12
func (t *Tags) DiscNumber() int { return intSep(t.firstTag("discnumber"), "/") } // eg. 1/2
func (t *Tags) Length() int { return t.props.Length }
func (t *Tags) Bitrate() int { return t.props.Bitrate }
func (t *Tags) Year() int { return intSep(t.firstTag("originaldate", "date", "year"), "-") }
func (t *Tags) SomeAlbum() string { return first("Unknown Album", t.Album()) }
func (t *Tags) SomeArtist() string { return first("Unknown Artist", t.Artist()) }
func (t *Tags) SomeAlbumArtist() string { return first("Unknown Artist", t.AlbumArtist(), t.Artist()) }
func (t *Tags) SomeGenre() string { return first("Unknown Genre", t.Genre()) }
func first(or string, strs ...string) string {
for _, str := range strs {
if str != "" {
return str
}
}
return or
type Parser interface {
Title() string
BrainzID() string
Artist() string
Album() string
AlbumArtist() string
AlbumBrainzID() string
Genre() string
TrackNumber() int
DiscNumber() int
Length() int
Bitrate() int
Year() int
SomeAlbum() string
SomeArtist() string
SomeAlbumArtist() string
SomeGenre() string
}

View File

@@ -146,8 +146,15 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su
if user.LastFMSession == "" {
return nil
}
apiKey := s.DB.GetSetting("lastfm_api_key")
secret := s.DB.GetSetting("lastfm_secret")
apiKey, err := s.DB.GetSetting("lastfm_api_key")
if err != nil {
return fmt.Errorf("get api key: %w", err)
}
secret, err := s.DB.GetSetting("lastfm_secret")
if err != nil {
return fmt.Errorf("get secret: %w", err)
}
// fetch user to get lastfm session
if user.LastFMSession == "" {
return fmt.Errorf("you don't have a last.fm session: %w", ErrLastFM)
@@ -169,7 +176,7 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su
params.Add("mbid", track.TagBrainzID)
params.Add("albumArtist", track.Artist.Name)
params.Add("api_sig", getParamSignature(params, secret))
_, err := makeRequest("POST", params)
_, err = makeRequest("POST", params)
return err
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/wader/gormstore"
"go.senan.xyz/gonic/server/assets"
@@ -18,6 +19,7 @@ import (
"go.senan.xyz/gonic/server/jukebox"
"go.senan.xyz/gonic/server/podcasts"
"go.senan.xyz/gonic/server/scanner"
"go.senan.xyz/gonic/server/scanner/tags"
"go.senan.xyz/gonic/server/scrobble"
"go.senan.xyz/gonic/server/scrobble/lastfm"
"go.senan.xyz/gonic/server/scrobble/listenbrainz"
@@ -48,7 +50,9 @@ func New(opts Options) (*Server, error) {
opts.CachePath = filepath.Clean(opts.CachePath)
opts.PodcastPath = filepath.Clean(opts.PodcastPath)
scanner := scanner.New(opts.MusicPath, opts.DB, opts.GenreSplit)
tagger := &tags.TagReader{}
scanner := scanner.New(opts.MusicPaths, false, opts.DB, opts.GenreSplit, tagger)
base := &ctrlbase.Controller{
DB: opts.DB,
MusicPath: opts.MusicPath,
@@ -63,12 +67,21 @@ func New(opts Options) (*Server, error) {
}
r.Use(base.WithCORS)
sessKey := opts.DB.GetOrCreateKey("session_key")
sessKey, err := opts.DB.GetSetting("session_key")
if err != nil {
return nil, fmt.Errorf("get session key: %w", err)
}
if sessKey == "" {
if err := opts.DB.SetSetting("session_key", string(securecookie.GenerateRandomKey(32))); err != nil {
return nil, fmt.Errorf("set session key: %w", err)
}
}
sessDB := gormstore.New(opts.DB.DB, []byte(sessKey))
sessDB.SessionOpts.HttpOnly = true
sessDB.SessionOpts.SameSite = http.SameSiteLaxMode
podcast := &podcasts.Podcasts{DB: opts.DB, PodcastBasePath: opts.PodcastPath}
podcast := podcasts.New(opts.DB, opts.PodcastPath, tagger)
ctrlAdmin, err := ctrladmin.New(base, sessDB, podcast)
if err != nil {
@@ -78,11 +91,10 @@ func New(opts Options) (*Server, error) {
Controller: base,
CachePath: opts.CachePath,
CoverCachePath: opts.CoverCachePath,
Scrobblers: []scrobble.Scrobbler{
&lastfm.Scrobbler{DB: opts.DB},
&listenbrainz.Scrobbler{},
},
Podcasts: podcast,
PodcastsPath: opts.PodcastPath,
Jukebox: &jukebox.Jukebox{},
Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}},
Podcasts: podcast,
}
setupMisc(r, base)
@@ -272,7 +284,7 @@ func (s *Server) StartScanTicker(dur time.Duration) (FuncExecute, FuncInterrupt)
return nil
case <-ticker.C:
go func() {
if err := s.scanner.Start(scanner.ScanOptions{}); err != nil {
if err := s.scanner.ScanAndClean(scanner.ScanOptions{}); err != nil {
log.Printf("error scanning: %v", err)
}
}()