seperate routes, provide robust handler types, use mux

This commit is contained in:
sentriz
2019-07-14 19:32:36 +01:00
parent cbe709025e
commit 5444b328fd
77 changed files with 11880 additions and 1011 deletions

View File

@@ -0,0 +1,98 @@
package ctrlsubsonic
import (
"encoding/json"
"encoding/xml"
"io"
"log"
"net/http"
"senan.xyz/g/gonic/server/ctrlbase"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/parsing"
)
type Controller struct {
*ctrlbase.Controller
}
func New(base *ctrlbase.Controller) *Controller {
return &Controller{
Controller: base,
}
}
type metaResponse struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
*spec.Response `json:"subsonic-response"`
}
type errWriter struct {
w io.Writer
err error
}
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}
func writeResp(w http.ResponseWriter, r *http.Request, resp *spec.Response) {
res := metaResponse{Response: resp}
ew := &errWriter{w: w}
switch parsing.GetStrParam(r, "f") {
case "json":
w.Header().Set("Content-Type", "application/json")
data, err := json.Marshal(res)
if err != nil {
log.Printf("could not marshall to json: %v\n", err)
return
}
ew.write(data)
case "jsonp":
w.Header().Set("Content-Type", "application/javascript")
data, err := json.Marshal(res)
if err != nil {
log.Printf("could not marshall to json: %v\n", err)
return
}
ew.write([]byte(parsing.GetStrParamOr(r, "callback", "cb")))
ew.write([]byte("("))
ew.write(data)
ew.write([]byte(");"))
default:
w.Header().Set("Content-Type", "application/xml")
data, err := xml.MarshalIndent(res, "", " ")
if err != nil {
log.Printf("could not marshall to xml: %v\n", err)
return
}
ew.write(data)
}
if ew.err != nil {
log.Printf("error writing to response: %v\n", ew.err)
}
}
type subsonicHandler func(r *http.Request) *spec.Response
func (c *Controller) H(h subsonicHandler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: write a non 200 if has err
response := h(r)
writeResp(w, r, response)
})
}
type subsonicHandlerRaw func(w http.ResponseWriter, r *http.Request) *spec.Response
func (c *Controller) HR(h subsonicHandlerRaw) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: write a non 200 if has err
// TODO: ensure no mixed return/writer
response := h(w, r)
writeResp(w, r, response)
})
}

View File

@@ -0,0 +1,86 @@
package ctrlsubsonic
import (
"log"
"net/http"
"net/http/httptest"
"net/url"
"path"
"regexp"
"strings"
"testing"
jd "github.com/josephburnett/jd/lib"
"senan.xyz/g/gonic/db"
"senan.xyz/g/gonic/server/ctrlbase"
)
var (
testDataDir = "testdata"
testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])")
testDBPath = path.Join(testDataDir, "db")
testController *Controller
)
func init() {
db, err := db.New(testDBPath)
if err != nil {
log.Fatalf("error opening database: %v\n", err)
}
testController = New(&ctrlbase.Controller{DB: db})
}
type queryCase struct {
params url.Values
expectPath string
listSet bool
}
func runQueryCases(t *testing.T, h subsonicHandler, cases []*queryCase) {
for _, qc := range cases {
qc := qc // pin
t.Run(qc.expectPath, func(t *testing.T) {
t.Parallel()
//
// ensure the handlers give us json
qc.params.Add("f", "json")
//
// request from the handler in question
req, _ := http.NewRequest("", "?"+qc.params.Encode(), nil)
rr := httptest.NewRecorder()
testController.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)
}
//
// convert test name to query case path
snake := testCamelExpr.ReplaceAllString(t.Name(), "${1}_${2}")
lower := strings.ToLower(snake)
relPath := strings.Replace(lower, "/", "_", -1)
absExpPath := path.Join(testDataDir, relPath)
//
// read case to differ with handler result
expected, err := jd.ReadJsonFile(absExpPath)
if err != nil {
t.Fatalf("parsing expected: %v", err)
}
actual, _ := jd.ReadJsonString(body)
if err != nil {
t.Fatalf("parsing actual: %v", err)
}
diffOpts := []jd.Metadata{}
if qc.listSet {
diffOpts = append(diffOpts, jd.SET)
}
diff := expected.Diff(actual, diffOpts...)
//
// pass or fail
if len(diff) == 0 {
return
}
t.Errorf("\u001b[31;1mdiffering json\u001b[0m\n%s", diff.Render())
})
}
}

View File

@@ -0,0 +1,216 @@
package ctrlsubsonic
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/jinzhu/gorm"
"senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/parsing"
)
// the subsonic spec metions "artist" a lot when talking about the
// browse by folder endpoints. but since we're not browsing by tag
// we can't access artists. so instead we'll consider the artist of
// an track to be the it's respective folder that comes directly
// under the root directory
func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response {
var folders []*model.Album
c.DB.
Select("*, count(sub.id) as child_count").
Joins(`
LEFT JOIN albums sub
ON albums.id = sub.parent_id
`).
Where("albums.parent_id = 1").
Group("albums.id").
Find(&folders)
// [a-z#] -> 27
indexMap := make(map[string]*spec.Index, 27)
resp := make([]*spec.Index, 0, 27)
for _, folder := range folders {
i := lowerUDecOrHash(folder.IndexRightPath())
index, ok := indexMap[i]
if !ok {
index = &spec.Index{
Name: i,
Artists: []*spec.Artist{},
}
indexMap[i] = index
resp = append(resp, index)
}
index.Artists = append(index.Artists,
spec.NewArtistByFolder(folder))
}
sort.Slice(resp, func(i, j int) bool {
return resp[i].Name < resp[j].Name
})
sub := spec.NewResponse()
sub.Indexes = &spec.Indexes{
LastModified: 0,
Index: resp,
}
return sub
}
func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
childrenObj := []*spec.TrackChild{}
folder := &model.Album{}
c.DB.First(folder, id)
//
// start looking for child childFolders in the current dir
var childFolders []*model.Album
c.DB.
Where("parent_id = ?", id).
Find(&childFolders)
for _, c := range childFolders {
childrenObj = append(childrenObj, spec.NewTCAlbumByFolder(c))
}
//
// start looking for child childTracks in the current dir
var childTracks []*model.Track
c.DB.
Where("album_id = ?", id).
Preload("Album").
Order("filename").
Find(&childTracks)
for _, c := range childTracks {
toAppend := spec.NewTCTrackByFolder(c, folder)
if parsing.GetStrParam(r, "c") == "Jamstash" {
// jamstash thinks it can't play flacs
toAppend.ContentType = "audio/mpeg"
toAppend.Suffix = "mp3"
}
childrenObj = append(childrenObj, toAppend)
}
//
// respond section
sub := spec.NewResponse()
sub.Directory = spec.NewDirectoryByFolder(folder, childrenObj)
return sub
}
// changes to this function should be reflected in in _by_tags.go's
// getAlbumListTwo() function
func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response {
listType := parsing.GetStrParam(r, "type")
if listType == "" {
return spec.NewError(10, "please provide a `type` parameter")
}
q := c.DB.DB
switch listType {
case "alphabeticalByArtist":
q = q.Joins(`
JOIN albums AS parent_albums
ON albums.parent_id = parent_albums.id`)
q = q.Order("parent_albums.right_path")
case "alphabeticalByName":
q = q.Order("right_path")
case "frequent":
user := r.Context().Value(key.User).(*model.User)
q = q.Joins(`
JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`,
user.ID)
q = q.Order("plays.count DESC")
case "newest":
q = q.Order("modified_at DESC")
case "random":
q = q.Order(gorm.Expr("random()"))
case "recent":
user := r.Context().Value(key.User).(*model.User)
q = q.Joins(`
JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`,
user.ID)
q = q.Order("plays.time DESC")
default:
return spec.NewError(10, "unknown value `%s` for parameter 'type'", listType)
}
var folders []*model.Album
q.
Where("albums.tag_artist_id IS NOT NULL").
Offset(parsing.GetIntParamOr(r, "offset", 0)).
Limit(parsing.GetIntParamOr(r, "size", 10)).
Preload("Parent").
Find(&folders)
sub := spec.NewResponse()
sub.Albums = &spec.Albums{
List: make([]*spec.Album, len(folders)),
}
for i, folder := range folders {
sub.Albums.List[i] = spec.NewAlbumByFolder(folder)
}
return sub
}
func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response {
query := parsing.GetStrParam(r, "query")
if query == "" {
return spec.NewError(10, "please provide a `query` parameter")
}
query = fmt.Sprintf("%%%s%%",
strings.TrimSuffix(query, "*"))
results := &spec.SearchResultTwo{}
//
// search "artists"
var artists []*model.Album
c.DB.
Where(`
parent_id = 1
AND (right_path LIKE ? OR
right_path_u_dec LIKE ?)
`, query, query).
Offset(parsing.GetIntParamOr(r, "artistOffset", 0)).
Limit(parsing.GetIntParamOr(r, "artistCount", 20)).
Find(&artists)
for _, a := range artists {
results.Artists = append(results.Artists,
spec.NewDirectoryByFolder(a, nil))
}
//
// search "albums"
var albums []*model.Album
c.DB.
Where(`
tag_artist_id IS NOT NULL
AND (right_path LIKE ? OR
right_path_u_dec LIKE ?)
`, query, query).
Offset(parsing.GetIntParamOr(r, "albumOffset", 0)).
Limit(parsing.GetIntParamOr(r, "albumCount", 20)).
Find(&albums)
for _, a := range albums {
results.Albums = append(results.Albums, spec.NewTCAlbumByFolder(a))
}
//
// search tracks
var tracks []*model.Track
c.DB.
Preload("Album").
Where(`
filename LIKE ? OR
filename_u_dec LIKE ?
`, query, query).
Offset(parsing.GetIntParamOr(r, "songOffset", 0)).
Limit(parsing.GetIntParamOr(r, "songCount", 20)).
Find(&tracks)
for _, t := range tracks {
results.Tracks = append(results.Tracks,
spec.NewTCTrackByFolder(t, t.Album))
}
//
sub := spec.NewResponse()
sub.SearchResultTwo = results
return sub
}

View File

@@ -0,0 +1,38 @@
package ctrlsubsonic
import (
"net/url"
"testing"
_ "github.com/jinzhu/gorm/dialects/sqlite"
)
func TestGetIndexes(t *testing.T) {
runQueryCases(t, testController.ServeGetIndexes, []*queryCase{
{url.Values{}, "no_args", false},
})
}
func TestGetMusicDirectory(t *testing.T) {
runQueryCases(t, testController.ServeGetMusicDirectory, []*queryCase{
{url.Values{"id": []string{"2"}}, "without_tracks", false},
{url.Values{"id": []string{"3"}}, "with_tracks", false},
})
}
func TestGetAlbumList(t *testing.T) {
runQueryCases(t, testController.ServeGetAlbumList, []*queryCase{
{url.Values{"type": []string{"alphabeticalByArtist"}}, "alpha_artist", false},
{url.Values{"type": []string{"alphabeticalByName"}}, "alpha_name", false},
{url.Values{"type": []string{"newest"}}, "newest", false},
{url.Values{"type": []string{"random"}, "size": []string{"15"}}, "random", true},
})
}
func TestSearchTwo(t *testing.T) {
runQueryCases(t, testController.ServeSearchTwo, []*queryCase{
{url.Values{"query": []string{"13"}}, "q_13", false},
{url.Values{"query": []string{"ani"}}, "q_ani", false},
{url.Values{"query": []string{"cert"}}, "q_cert", false},
})
}

View File

@@ -0,0 +1,215 @@
package ctrlsubsonic
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/jinzhu/gorm"
"senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/parsing"
)
func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response {
var artists []*model.Artist
c.DB.
Select("*, count(sub.id) as album_count").
Joins(`
LEFT JOIN albums sub
ON artists.id = sub.tag_artist_id
`).
Group("artists.id").
Find(&artists)
// [a-z#] -> 27
indexMap := make(map[string]*spec.Index, 27)
resp := make([]*spec.Index, 0, 27)
for _, artist := range artists {
i := lowerUDecOrHash(artist.IndexName())
index, ok := indexMap[i]
if !ok {
index = &spec.Index{
Name: i,
Artists: []*spec.Artist{},
}
indexMap[i] = index
resp = append(resp, index)
}
index.Artists = append(index.Artists,
spec.NewArtistByTags(artist))
}
sort.Slice(resp, func(i, j int) bool {
return resp[i].Name < resp[j].Name
})
sub := spec.NewResponse()
sub.Artists = &spec.Artists{
List: resp,
}
return sub
}
func (c *Controller) ServeGetArtist(r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
artist := &model.Artist{}
c.DB.
Preload("Albums").
First(artist, id)
sub := spec.NewResponse()
sub.Artist = spec.NewArtistByTags(artist)
sub.Artist.Albums = make([]*spec.Album, len(artist.Albums))
for i, album := range artist.Albums {
sub.Artist.Albums[i] = spec.NewAlbumByTags(album, artist)
}
return sub
}
func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
album := &model.Album{}
err = c.DB.
Preload("TagArtist").
Preload("Tracks", func(db *gorm.DB) *gorm.DB {
return db.Order("tracks.tag_disc_number, tracks.tag_track_number")
}).
First(album, id).
Error
if gorm.IsRecordNotFoundError(err) {
return spec.NewError(10, "couldn't find an album with that id")
}
sub := spec.NewResponse()
sub.Album = spec.NewAlbumByTags(album, album.TagArtist)
sub.Album.Tracks = make([]*spec.TrackChild, len(album.Tracks))
for i, track := range album.Tracks {
sub.Album.Tracks[i] = spec.NewTrackByTags(track, album)
}
return sub
}
// changes to this function should be reflected in in _by_folder.go's
// getAlbumList() function
func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response {
listType := parsing.GetStrParam(r, "type")
if listType == "" {
return spec.NewError(10, "please provide a `type` parameter")
}
q := c.DB.DB
switch listType {
case "alphabeticalByArtist":
q = q.Joins(`
JOIN artists
ON albums.tag_artist_id = artists.id`)
q = q.Order("artists.name")
case "alphabeticalByName":
q = q.Order("tag_title")
case "byYear":
q = q.Where(
"tag_year BETWEEN ? AND ?",
parsing.GetIntParamOr(r, "fromYear", 1800),
parsing.GetIntParamOr(r, "toYear", 2200))
q = q.Order("tag_year")
case "frequent":
user := r.Context().Value(key.User).(*model.User)
q = q.Joins(`
JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`,
user.ID)
q = q.Order("plays.count DESC")
case "newest":
q = q.Order("modified_at DESC")
case "random":
q = q.Order(gorm.Expr("random()"))
case "recent":
user := r.Context().Value(key.User).(*model.User)
q = q.Joins(`
JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`,
user.ID)
q = q.Order("plays.time DESC")
default:
return spec.NewError(10, "unknown value `%s` for parameter 'type'", listType)
}
var albums []*model.Album
q.
Where("albums.tag_artist_id IS NOT NULL").
Offset(parsing.GetIntParamOr(r, "offset", 0)).
Limit(parsing.GetIntParamOr(r, "size", 10)).
Preload("TagArtist").
Find(&albums)
sub := spec.NewResponse()
sub.AlbumsTwo = &spec.Albums{
List: make([]*spec.Album, len(albums)),
}
for i, album := range albums {
sub.AlbumsTwo.List[i] = spec.NewAlbumByTags(album, album.TagArtist)
}
return sub
}
func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response {
query := parsing.GetStrParam(r, "query")
if query == "" {
return spec.NewError(10, "please provide a `query` parameter")
}
query = fmt.Sprintf("%%%s%%",
strings.TrimSuffix(query, "*"))
results := &spec.SearchResultThree{}
//
// search "artists"
var artists []*model.Artist
c.DB.
Where(`
name LIKE ? OR
name_u_dec LIKE ?
`, query, query).
Offset(parsing.GetIntParamOr(r, "artistOffset", 0)).
Limit(parsing.GetIntParamOr(r, "artistCount", 20)).
Find(&artists)
for _, a := range artists {
results.Artists = append(results.Artists,
spec.NewArtistByTags(a))
}
//
// search "albums"
var albums []*model.Album
c.DB.
Preload("TagArtist").
Where(`
tag_title LIKE ? OR
tag_title_u_dec LIKE ?
`, query, query).
Offset(parsing.GetIntParamOr(r, "albumOffset", 0)).
Limit(parsing.GetIntParamOr(r, "albumCount", 20)).
Find(&albums)
for _, a := range albums {
results.Albums = append(results.Albums,
spec.NewAlbumByTags(a, a.TagArtist))
}
//
// search tracks
var tracks []*model.Track
c.DB.
Preload("Album").
Where(`
tag_title LIKE ? OR
tag_title_u_dec LIKE ?
`, query, query).
Offset(parsing.GetIntParamOr(r, "songOffset", 0)).
Limit(parsing.GetIntParamOr(r, "songCount", 20)).
Find(&tracks)
for _, t := range tracks {
results.Tracks = append(results.Tracks,
spec.NewTrackByTags(t, t.Album))
}
sub := spec.NewResponse()
sub.SearchResultThree = results
return sub
}

View File

@@ -0,0 +1,44 @@
package ctrlsubsonic
import (
"net/url"
"testing"
)
func TestGetArtists(t *testing.T) {
runQueryCases(t, testController.ServeGetArtists, []*queryCase{
{url.Values{}, "no_args", false},
})
}
func TestGetArtist(t *testing.T) {
runQueryCases(t, testController.ServeGetArtist, []*queryCase{
{url.Values{"id": []string{"1"}}, "id_one", false},
{url.Values{"id": []string{"2"}}, "id_two", false},
{url.Values{"id": []string{"3"}}, "id_three", false},
})
}
func TestGetAlbum(t *testing.T) {
runQueryCases(t, testController.ServeGetAlbum, []*queryCase{
{url.Values{"id": []string{"2"}}, "without_cover", false},
{url.Values{"id": []string{"3"}}, "with_cover", false},
})
}
func TestGetAlbumListTwo(t *testing.T) {
runQueryCases(t, testController.ServeGetAlbumListTwo, []*queryCase{
{url.Values{"type": []string{"alphabeticalByArtist"}}, "alpha_artist", false},
{url.Values{"type": []string{"alphabeticalByName"}}, "alpha_name", false},
{url.Values{"type": []string{"newest"}}, "newest", false},
{url.Values{"type": []string{"random"}, "size": []string{"15"}}, "random", true},
})
}
func TestSearchThree(t *testing.T) {
runQueryCases(t, testController.ServeSearchThree, []*queryCase{
{url.Values{"query": []string{"13"}}, "q_13", false},
{url.Values{"query": []string{"ani"}}, "q_ani", false},
{url.Values{"query": []string{"cert"}}, "q_cert", false},
})
}

View File

@@ -0,0 +1,107 @@
package ctrlsubsonic
import (
"log"
"net/http"
"time"
"unicode"
"senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/scanner"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/lastfm"
"senan.xyz/g/gonic/server/parsing"
)
func lowerUDecOrHash(in string) string {
lower := unicode.ToLower(rune(in[0]))
if !unicode.IsLetter(lower) {
return "#"
}
return string(lower)
}
func (c *Controller) ServeGetLicence(r *http.Request) *spec.Response {
sub := spec.NewResponse()
sub.Licence = &spec.Licence{
Valid: true,
}
return sub
}
func (c *Controller) ServePing(r *http.Request) *spec.Response {
return spec.NewResponse()
}
func (c *Controller) ServeScrobble(r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
// fetch user to get lastfm session
user := r.Context().Value(key.User).(*model.User)
if user.LastFMSession == "" {
return spec.NewError(0, "you don't have a last.fm session")
}
// fetch track for getting info to send to last.fm function
track := &model.Track{}
c.DB.
Preload("Album").
Preload("Artist").
First(track, id)
// scrobble with above info
err = lastfm.Scrobble(
c.DB.GetSetting("lastfm_api_key"),
c.DB.GetSetting("lastfm_secret"),
user.LastFMSession,
track,
// clients will provide time in miliseconds, so use that or
// instead convert UnixNano to miliseconds
parsing.GetIntParamOr(r, "time", int(time.Now().UnixNano()/1e6)),
parsing.GetStrParamOr(r, "submission", "true") != "false",
)
if err != nil {
return spec.NewError(0, "error when submitting: %v", err)
}
return spec.NewResponse()
}
func (c *Controller) ServeGetMusicFolders(r *http.Request) *spec.Response {
folders := &spec.MusicFolders{}
folders.List = []*spec.MusicFolder{
{ID: 1, Name: "music"},
}
sub := spec.NewResponse()
sub.MusicFolders = folders
return sub
}
func (c *Controller) ServeStartScan(r *http.Request) *spec.Response {
go func() {
err := scanner.
New(c.DB, c.MusicPath).
Start()
if err != nil {
log.Printf("error while scanning: %v\n", err)
}
}()
return c.ServeGetScanStatus(r)
}
func (c *Controller) ServeGetScanStatus(r *http.Request) *spec.Response {
var trackCount int
c.DB.
Model(model.Track{}).
Count(&trackCount)
sub := spec.NewResponse()
sub.ScanStatus = &spec.ScanStatus{
Scanning: scanner.IsScanning(),
Count: trackCount,
}
return sub
}
func (c *Controller) ServeNotFound(r *http.Request) *spec.Response {
return spec.NewError(70, "view not found")
}

View File

@@ -0,0 +1,88 @@
package ctrlsubsonic
import (
"net/http"
"os"
"path"
"time"
"github.com/jinzhu/gorm"
"senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/parsing"
)
// "raw" handlers are ones that don't always return a spec response.
// it could be a file, stream, etc. so you must either
// a) write to response writer
// b) return a non-nil spec.Response
// _but not both_
func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
folder := &model.Album{}
err = c.DB.
Select("id, left_path, right_path, cover").
First(folder, id).
Error
if gorm.IsRecordNotFoundError(err) {
return spec.NewError(10, "could not find a cover with that id")
}
if folder.Cover == "" {
return spec.NewError(10, "no cover found for that folder")
}
absPath := path.Join(
c.MusicPath,
folder.LeftPath,
folder.RightPath,
folder.Cover,
)
http.ServeFile(w, r, absPath)
return nil
}
func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
track := &model.Track{}
err = c.DB.
Preload("Album").
First(track, id).
Error
if gorm.IsRecordNotFoundError(err) {
return spec.NewError(70, "media with id `%d` was not found", id)
}
absPath := path.Join(
c.MusicPath,
track.Album.LeftPath,
track.Album.RightPath,
track.Filename,
)
file, err := os.Open(absPath)
if err != nil {
return spec.NewError(0, "error while streaming media: %v", err)
}
stat, _ := file.Stat()
http.ServeContent(w, r, absPath, stat.ModTime(), file)
//
// after we've served the file, mark the album as played
user := r.Context().Value(key.User).(*model.User)
play := model.Play{
AlbumID: track.Album.ID,
UserID: user.ID,
}
c.DB.
Where(play).
First(&play)
play.Time = time.Now() // for getAlbumList?type=recent
play.Count++ // for getAlbumList?type=frequent
c.DB.Save(&play)
return nil
}

View File

@@ -0,0 +1,80 @@
package ctrlsubsonic
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/parsing"
)
var requiredParameters = []string{
"u", "v", "c",
}
func checkHasAllParams(params url.Values) error {
for _, req := range requiredParameters {
param := params.Get(req)
if param != "" {
continue
}
return fmt.Errorf("please provide a `%s` parameter", req)
}
return nil
}
func checkCredsToken(password, token, salt string) bool {
toHash := fmt.Sprintf("%s%s", password, salt)
hash := md5.Sum([]byte(toHash))
expToken := hex.EncodeToString(hash[:])
return token == expToken
}
func checkCredsBasic(password, given string) bool {
if len(given) >= 4 && given[:4] == "enc:" {
bytes, _ := hex.DecodeString(given[4:])
given = string(bytes)
}
return password == given
}
func (c *Controller) WithValidSubsonicArgs(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := checkHasAllParams(r.URL.Query()); err != nil {
writeResp(w, r, spec.NewError(10, err.Error()))
return
}
username := parsing.GetStrParam(r, "u")
password := parsing.GetStrParam(r, "p")
token := parsing.GetStrParam(r, "t")
salt := parsing.GetStrParam(r, "s")
passwordAuth := token == "" && salt == ""
tokenAuth := password == ""
if tokenAuth == passwordAuth {
writeResp(w, r, spec.NewError(10, "please provide `t` and `s`, or just `p`"))
return
}
user := c.DB.GetUserFromName(username)
if user == nil {
writeResp(w, r, spec.NewError(40, "invalid username `%s`", username))
return
}
var credsOk bool
if tokenAuth {
credsOk = checkCredsToken(user.Password, token, salt)
} else {
credsOk = checkCredsBasic(user.Password, password)
}
if !credsOk {
writeResp(w, r, spec.NewError(40, "invalid password"))
return
}
withUser := context.WithValue(r.Context(), key.User, user)
next.ServeHTTP(w, r.WithContext(withUser))
})
}

View File

@@ -0,0 +1,76 @@
package spec
import (
"path"
"senan.xyz/g/gonic/model"
)
func NewAlbumByFolder(f *model.Album) *Album {
return &Album{
Artist: f.Parent.RightPath,
CoverID: f.ID,
ID: f.ID,
IsDir: true,
ParentID: f.ParentID,
Title: f.RightPath,
}
}
func NewTCAlbumByFolder(f *model.Album) *TrackChild {
trCh := &TrackChild{
ID: f.ID,
IsDir: true,
Title: f.RightPath,
ParentID: f.ParentID,
}
if f.Cover != "" {
trCh.CoverID = f.ID
}
return trCh
}
func NewTCTrackByFolder(t *model.Track, parent *model.Album) *TrackChild {
trCh := &TrackChild{
ID: t.ID,
Album: t.Album.RightPath,
ContentType: t.MIME(),
Suffix: t.Ext(),
Size: t.Size,
Artist: t.TagTrackArtist,
Title: t.TagTitle,
TrackNumber: t.TagTrackNumber,
DiscNumber: t.TagDiscNumber,
Path: path.Join(
parent.LeftPath,
parent.RightPath,
t.Filename,
),
ParentID: parent.ID,
Duration: t.Length,
Bitrate: t.Bitrate,
IsDir: false,
Type: "music",
}
if parent.Cover != "" {
trCh.CoverID = parent.ID
}
return trCh
}
func NewArtistByFolder(f *model.Album) *Artist {
return &Artist{
ID: f.ID,
Name: f.RightPath,
AlbumCount: f.ChildCount,
}
}
func NewDirectoryByFolder(f *model.Album, children []*TrackChild) *Directory {
return &Directory{
ID: f.ID,
Parent: f.ParentID,
Name: f.RightPath,
Children: children,
}
}

View File

@@ -0,0 +1,61 @@
package spec
import (
"path"
"senan.xyz/g/gonic/model"
)
func NewAlbumByTags(a *model.Album, artist *model.Artist) *Album {
ret := &Album{
Created: a.ModifiedAt,
ID: a.ID,
Name: a.TagTitle,
}
if a.Cover != "" {
ret.CoverID = a.ID
}
if artist != nil {
ret.Artist = artist.Name
ret.ArtistID = artist.ID
}
return ret
}
func NewTrackByTags(t *model.Track, album *model.Album) *TrackChild {
ret := &TrackChild{
ID: t.ID,
ContentType: t.MIME(),
Suffix: t.Ext(),
ParentID: t.AlbumID,
CreatedAt: t.CreatedAt,
Size: t.Size,
Title: t.TagTitle,
Artist: t.TagTrackArtist,
TrackNumber: t.TagTrackNumber,
DiscNumber: t.TagDiscNumber,
Path: path.Join(
album.LeftPath,
album.RightPath,
t.Filename,
),
Album: album.TagTitle,
AlbumID: album.ID,
ArtistID: album.TagArtist.ID,
Duration: t.Length,
Bitrate: t.Bitrate,
Type: "music",
}
if album.Cover != "" {
ret.CoverID = album.ID
}
return ret
}
func NewArtistByTags(a *model.Artist) *Artist {
return &Artist{
ID: a.ID,
Name: a.Name,
AlbumCount: a.AlbumCount,
}
}

View File

@@ -0,0 +1,179 @@
package spec
import (
"fmt"
"time"
)
var (
apiVersion = "1.9.0"
xmlns = "http://subsonic.org/restapi"
)
type Response struct {
Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"`
XMLNS string `xml:"xmlns,attr" json:"-"`
Error *Error `xml:"error" json:"error,omitempty"`
Albums *Albums `xml:"albumList" json:"albumList,omitempty"`
AlbumsTwo *Albums `xml:"albumList2" json:"albumList2,omitempty"`
Album *Album `xml:"album" json:"album,omitempty"`
Track *TrackChild `xml:"song" json:"song,omitempty"`
Indexes *Indexes `xml:"indexes" json:"indexes,omitempty"`
Artists *Artists `xml:"artists" json:"artists,omitempty"`
Artist *Artist `xml:"artist" json:"artist,omitempty"`
Directory *Directory `xml:"directory" json:"directory,omitempty"`
RandomTracks *RandomTracks `xml:"randomSongs" json:"randomSongs,omitempty"`
MusicFolders *MusicFolders `xml:"musicFolders" json:"musicFolders,omitempty"`
ScanStatus *ScanStatus `xml:"scanStatus" json:"scanStatus,omitempty"`
Licence *Licence `xml:"license" json:"license,omitempty"`
SearchResultTwo *SearchResultTwo `xml:"searchResult2" json:"searchResult2,omitempty"`
SearchResultThree *SearchResultThree `xml:"searchResult3" json:"searchResult3,omitempty"`
}
func NewResponse() *Response {
return &Response{
Status: "ok",
XMLNS: xmlns,
Version: apiVersion,
}
}
// spec errors:
// 0 a generic error
// 10 required parameter is missing
// 20 incompatible subsonic rest protocol version. client must upgrade
// 30 incompatible subsonic rest protocol version. server must upgrade
// 40 wrong username or password
// 41 token authentication not supported for ldap users
// 50 user is not authorized for the given operation
// 60 the trial period for the subsonic server is over
// 70 the requested data was not found
type Error struct {
Code int `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"`
}
func NewError(code int, message string, a ...interface{}) *Response {
return &Response{
Status: "failed",
XMLNS: xmlns,
Version: apiVersion,
Error: &Error{
Code: code,
Message: fmt.Sprintf(message, a...),
},
}
}
type Albums struct {
List []*Album `xml:"album" json:"album,omitempty"`
}
type Album struct {
// common
ID int `xml:"id,attr,omitempty" json:"id"`
CoverID int `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
ArtistID int `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
// browsing by folder (getAlbumList)
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
ParentID int `xml:"parent,attr,omitempty" json:"parent,omitempty"`
IsDir bool `xml:"isDir,attr,omitempty" json:"isDir,omitempty"`
// browsing by tags (getAlbumList2)
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
TrackCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Created time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Tracks []*TrackChild `xml:"song,omitempty" json:"song,omitempty"`
}
type RandomTracks struct {
Tracks []*TrackChild `xml:"song" json:"song"`
}
type TrackChild struct {
Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
AlbumID int `xml:"albumId,attr,omitempty" json:"albumId,omitempty"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
ArtistID int `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
Bitrate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"`
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
CoverID int `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
CreatedAt time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
ID int `xml:"id,attr,omitempty" json:"id,omitempty"`
IsDir bool `xml:"isDir,attr,omitempty" json:"isDir,omitempty"`
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
ParentID int `xml:"parent,attr,omitempty" json:"parent,omitempty"`
Path string `xml:"path,attr,omitempty" json:"path,omitempty"`
Size int `xml:"size,attr,omitempty" json:"size,omitempty"`
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
TrackNumber int `xml:"track,attr,omitempty" json:"track,omitempty"`
DiscNumber int `xml:"discNumber,attr,omitempty" json:"discNumber,omitempty"`
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
}
type Artists struct {
List []*Index `xml:"index,omitempty" json:"index,omitempty"`
}
type Artist struct {
ID int `xml:"id,attr,omitempty" json:"id"`
Name string `xml:"name,attr,omitempty" json:"name"`
CoverID int `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
AlbumCount int `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
Albums []*Album `xml:"album,omitempty" json:"album,omitempty"`
}
type Indexes struct {
LastModified int `xml:"lastModified,attr,omitempty" json:"lastModified"`
Index []*Index `xml:"index,omitempty" json:"index"`
}
type Index struct {
Name string `xml:"name,attr,omitempty" json:"name"`
Artists []*Artist `xml:"artist,omitempty" json:"artist"`
}
type Directory struct {
ID int `xml:"id,attr,omitempty" json:"id"`
Parent int `xml:"parent,attr,omitempty" json:"parent"`
Name string `xml:"name,attr,omitempty" json:"name"`
Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"`
Children []*TrackChild `xml:"child,omitempty" json:"child,omitempty"`
}
type MusicFolders struct {
List []*MusicFolder `xml:"musicFolder,omitempty" json:"musicFolder,omitempty"`
}
type MusicFolder struct {
ID int `xml:"id,attr,omitempty" json:"id,omitempty"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
}
type Licence struct {
Valid bool `xml:"valid,attr,omitempty" json:"valid,omitempty"`
}
type ScanStatus struct {
Scanning bool `xml:"scanning,attr" json:"scanning"`
Count int `xml:"count,attr,omitempty" json:"count,omitempty"`
}
type SearchResultTwo struct {
Artists []*Directory `xml:"artist,omitempty" json:"artist,omitempty"`
Albums []*TrackChild `xml:"album,omitempty" json:"album,omitempty"`
Tracks []*TrackChild `xml:"song,omitempty" json:"song,omitempty"`
}
type SearchResultThree struct {
Artists []*Artist `xml:"artist,omitempty" json:"artist,omitempty"`
Albums []*Album `xml:"album,omitempty" json:"album,omitempty"`
Tracks []*TrackChild `xml:"song,omitempty" json:"song,omitempty"`
}

BIN
server/ctrlsubsonic/testdata/db vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,100 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"albumList": {
"album": [
{
"id": 8,
"coverArt": 8,
"artist": "13th Floor Lowervators",
"title": "(1967) Easter Nowhere",
"parent": 7,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 9,
"coverArt": 9,
"artist": "13th Floor Lowervators",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"parent": 7,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 5,
"coverArt": 5,
"artist": "A Certain Ratio",
"title": "(1994) The Graveyard and the Ballroom",
"parent": 4,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 6,
"coverArt": 6,
"artist": "A Certain Ratio",
"title": "(1981) To EachOTHER.",
"parent": 4,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 21,
"coverArt": 21,
"artist": "Captain Beefheart",
"title": "(1970) Lick My Decals Off, Bitch",
"parent": 20,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 3,
"coverArt": 3,
"artist": "Jah Wobble, The Edge, Holger Czukay",
"title": "(1983) Snake Charmer",
"parent": 2,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 16,
"coverArt": 16,
"artist": "Swell Maps",
"title": "(1980) Jane From Occupied Europe",
"parent": 15,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 17,
"coverArt": 17,
"artist": "Swell Maps",
"title": "(1979) A Trip to Marineville",
"parent": 15,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 19,
"coverArt": 19,
"artist": "Ten Years After",
"title": "(1967) Ten Years After",
"parent": 18,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 13,
"coverArt": 13,
"artist": "There",
"title": "(2010) Anika",
"parent": 12,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
}
]
}
}
}

View File

@@ -0,0 +1,100 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"albumList": {
"album": [
{
"id": 9,
"coverArt": 9,
"artist": "13th Floor Lowervators",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"parent": 7,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 8,
"coverArt": 8,
"artist": "13th Floor Lowervators",
"title": "(1967) Easter Nowhere",
"parent": 7,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 19,
"coverArt": 19,
"artist": "Ten Years After",
"title": "(1967) Ten Years After",
"parent": 18,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 21,
"coverArt": 21,
"artist": "Captain Beefheart",
"title": "(1970) Lick My Decals Off, Bitch",
"parent": 20,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 17,
"coverArt": 17,
"artist": "Swell Maps",
"title": "(1979) A Trip to Marineville",
"parent": 15,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 16,
"coverArt": 16,
"artist": "Swell Maps",
"title": "(1980) Jane From Occupied Europe",
"parent": 15,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 6,
"coverArt": 6,
"artist": "A Certain Ratio",
"title": "(1981) To EachOTHER.",
"parent": 4,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 3,
"coverArt": 3,
"artist": "Jah Wobble, The Edge, Holger Czukay",
"title": "(1983) Snake Charmer",
"parent": 2,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 5,
"coverArt": 5,
"artist": "A Certain Ratio",
"title": "(1994) The Graveyard and the Ballroom",
"parent": 4,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 13,
"coverArt": 13,
"artist": "There",
"title": "(2010) Anika",
"parent": 12,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
}
]
}
}
}

View File

@@ -0,0 +1,100 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"albumList": {
"album": [
{
"id": 8,
"coverArt": 8,
"artist": "13th Floor Lowervators",
"title": "(1967) Easter Nowhere",
"parent": 7,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 9,
"coverArt": 9,
"artist": "13th Floor Lowervators",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"parent": 7,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 21,
"coverArt": 21,
"artist": "Captain Beefheart",
"title": "(1970) Lick My Decals Off, Bitch",
"parent": 20,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 5,
"coverArt": 5,
"artist": "A Certain Ratio",
"title": "(1994) The Graveyard and the Ballroom",
"parent": 4,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 6,
"coverArt": 6,
"artist": "A Certain Ratio",
"title": "(1981) To EachOTHER.",
"parent": 4,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 13,
"coverArt": 13,
"artist": "There",
"title": "(2010) Anika",
"parent": 12,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 3,
"coverArt": 3,
"artist": "Jah Wobble, The Edge, Holger Czukay",
"title": "(1983) Snake Charmer",
"parent": 2,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 16,
"coverArt": 16,
"artist": "Swell Maps",
"title": "(1980) Jane From Occupied Europe",
"parent": 15,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 17,
"coverArt": 17,
"artist": "Swell Maps",
"title": "(1979) A Trip to Marineville",
"parent": 15,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 19,
"coverArt": 19,
"artist": "Ten Years After",
"title": "(1967) Ten Years After",
"parent": 18,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
}
]
}
}
}

View File

@@ -0,0 +1,100 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"albumList": {
"album": [
{
"id": 17,
"coverArt": 17,
"artist": "Swell Maps",
"title": "(1979) A Trip to Marineville",
"parent": 15,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 5,
"coverArt": 5,
"artist": "A Certain Ratio",
"title": "(1994) The Graveyard and the Ballroom",
"parent": 4,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 8,
"coverArt": 8,
"artist": "13th Floor Lowervators",
"title": "(1967) Easter Nowhere",
"parent": 7,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 19,
"coverArt": 19,
"artist": "Ten Years After",
"title": "(1967) Ten Years After",
"parent": 18,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 3,
"coverArt": 3,
"artist": "Jah Wobble, The Edge, Holger Czukay",
"title": "(1983) Snake Charmer",
"parent": 2,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 16,
"coverArt": 16,
"artist": "Swell Maps",
"title": "(1980) Jane From Occupied Europe",
"parent": 15,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 21,
"coverArt": 21,
"artist": "Captain Beefheart",
"title": "(1970) Lick My Decals Off, Bitch",
"parent": 20,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 13,
"coverArt": 13,
"artist": "There",
"title": "(2010) Anika",
"parent": 12,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 6,
"coverArt": 6,
"artist": "A Certain Ratio",
"title": "(1981) To EachOTHER.",
"parent": 4,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
},
{
"id": 9,
"coverArt": 9,
"artist": "13th Floor Lowervators",
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"parent": 7,
"isDir": true,
"created": "0001-01-01T00:00:00Z"
}
]
}
}
}

View File

@@ -0,0 +1,89 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"albumList2": {
"album": [
{
"id": 8,
"coverArt": 8,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "Easter Everywhere",
"created": "2019-06-13T12:57:28.850090338+01:00"
},
{
"id": 9,
"coverArt": 9,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"created": "2019-06-13T12:57:24.306717554+01:00"
},
{
"id": 5,
"coverArt": 5,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "The Graveyard and the Ballroom",
"created": "2019-06-05T17:46:37.675917974+01:00"
},
{
"id": 6,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "To Each...",
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": 13,
"coverArt": 13,
"artistId": 4,
"artist": "Anikas",
"name": "Anika",
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": 21,
"coverArt": 21,
"artistId": 7,
"artist": "Captain Beefheart & His Magic Band",
"name": "Lick My Decals Off, Baby",
"created": "2019-06-10T19:26:30.944742894+01:00"
},
{
"id": 3,
"coverArt": 3,
"artistId": 1,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"name": "Snake Charmer",
"created": "2019-05-16T22:10:52+01:00"
},
{
"id": 16,
"coverArt": 16,
"artistId": 5,
"artist": "Swell Maps",
"name": "Jane From Occupied Europe",
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": 17,
"coverArt": 17,
"artistId": 5,
"artist": "Swell Maps",
"name": "A Trip to Marineville",
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": 19,
"coverArt": 19,
"artistId": 6,
"artist": "Ten Years After",
"name": "Ten Years After",
"created": "2019-04-30T16:48:30+01:00"
}
]
}
}
}

View File

@@ -0,0 +1,89 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"albumList2": {
"album": [
{
"id": 17,
"coverArt": 17,
"artistId": 5,
"artist": "Swell Maps",
"name": "A Trip to Marineville",
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": 13,
"coverArt": 13,
"artistId": 4,
"artist": "Anikas",
"name": "Anika",
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": 8,
"coverArt": 8,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "Easter Everywhere",
"created": "2019-06-13T12:57:28.850090338+01:00"
},
{
"id": 16,
"coverArt": 16,
"artistId": 5,
"artist": "Swell Maps",
"name": "Jane From Occupied Europe",
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": 21,
"coverArt": 21,
"artistId": 7,
"artist": "Captain Beefheart & His Magic Band",
"name": "Lick My Decals Off, Baby",
"created": "2019-06-10T19:26:30.944742894+01:00"
},
{
"id": 3,
"coverArt": 3,
"artistId": 1,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"name": "Snake Charmer",
"created": "2019-05-16T22:10:52+01:00"
},
{
"id": 19,
"coverArt": 19,
"artistId": 6,
"artist": "Ten Years After",
"name": "Ten Years After",
"created": "2019-04-30T16:48:30+01:00"
},
{
"id": 5,
"coverArt": 5,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "The Graveyard and the Ballroom",
"created": "2019-06-05T17:46:37.675917974+01:00"
},
{
"id": 9,
"coverArt": 9,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"created": "2019-06-13T12:57:24.306717554+01:00"
},
{
"id": 6,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "To Each...",
"created": "2019-05-23T15:12:02.921473302+01:00"
}
]
}
}
}

View File

@@ -0,0 +1,89 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"albumList2": {
"album": [
{
"id": 8,
"coverArt": 8,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "Easter Everywhere",
"created": "2019-06-13T12:57:28.850090338+01:00"
},
{
"id": 9,
"coverArt": 9,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"created": "2019-06-13T12:57:24.306717554+01:00"
},
{
"id": 21,
"coverArt": 21,
"artistId": 7,
"artist": "Captain Beefheart & His Magic Band",
"name": "Lick My Decals Off, Baby",
"created": "2019-06-10T19:26:30.944742894+01:00"
},
{
"id": 5,
"coverArt": 5,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "The Graveyard and the Ballroom",
"created": "2019-06-05T17:46:37.675917974+01:00"
},
{
"id": 6,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "To Each...",
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": 13,
"coverArt": 13,
"artistId": 4,
"artist": "Anikas",
"name": "Anika",
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": 3,
"coverArt": 3,
"artistId": 1,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"name": "Snake Charmer",
"created": "2019-05-16T22:10:52+01:00"
},
{
"id": 16,
"coverArt": 16,
"artistId": 5,
"artist": "Swell Maps",
"name": "Jane From Occupied Europe",
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": 17,
"coverArt": 17,
"artistId": 5,
"artist": "Swell Maps",
"name": "A Trip to Marineville",
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": 19,
"coverArt": 19,
"artistId": 6,
"artist": "Ten Years After",
"name": "Ten Years After",
"created": "2019-04-30T16:48:30+01:00"
}
]
}
}
}

View File

@@ -0,0 +1,89 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"albumList2": {
"album": [
{
"id": 3,
"coverArt": 3,
"artistId": 1,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"name": "Snake Charmer",
"created": "2019-05-16T22:10:52+01:00"
},
{
"id": 13,
"coverArt": 13,
"artistId": 4,
"artist": "Anikas",
"name": "Anika",
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": 8,
"coverArt": 8,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "Easter Everywhere",
"created": "2019-06-13T12:57:28.850090338+01:00"
},
{
"id": 9,
"coverArt": 9,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"created": "2019-06-13T12:57:24.306717554+01:00"
},
{
"id": 19,
"coverArt": 19,
"artistId": 6,
"artist": "Ten Years After",
"name": "Ten Years After",
"created": "2019-04-30T16:48:30+01:00"
},
{
"id": 17,
"coverArt": 17,
"artistId": 5,
"artist": "Swell Maps",
"name": "A Trip to Marineville",
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": 5,
"coverArt": 5,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "The Graveyard and the Ballroom",
"created": "2019-06-05T17:46:37.675917974+01:00"
},
{
"id": 6,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "To Each...",
"created": "2019-05-23T15:12:02.921473302+01:00"
},
{
"id": 16,
"coverArt": 16,
"artistId": 5,
"artist": "Swell Maps",
"name": "Jane From Occupied Europe",
"created": "2019-04-30T16:48:48+01:00"
},
{
"id": 21,
"coverArt": 21,
"artistId": 7,
"artist": "Captain Beefheart & His Magic Band",
"name": "Lick My Decals Off, Baby",
"created": "2019-06-10T19:26:30.944742894+01:00"
}
]
}
}
}

View File

@@ -0,0 +1,116 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"album": {
"id": 3,
"coverArt": 3,
"artistId": 1,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"name": "Snake Charmer",
"created": "2019-05-16T22:10:52+01:00",
"song": [
{
"album": "Snake Charmer",
"albumId": 3,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artistId": 1,
"bitRate": 882,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "2019-07-08T21:49:40.978045401+01:00",
"duration": 372,
"id": 1,
"parent": 3,
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/01.05 Snake Charmer.flac",
"size": 41274185,
"suffix": "flac",
"title": "Snake Charmer",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"album": "Snake Charmer",
"albumId": 3,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artistId": 1,
"bitRate": 814,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "2019-07-08T21:49:40.981605306+01:00",
"duration": 523,
"id": 3,
"parent": 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"
},
{
"album": "Snake Charmer",
"albumId": 3,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artistId": 1,
"bitRate": 745,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "2019-07-08T21:49:40.979981084+01:00",
"duration": 331,
"id": 2,
"parent": 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"
},
{
"album": "Snake Charmer",
"albumId": 3,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artistId": 1,
"bitRate": 976,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "2019-07-08T21:49:40.984853203+01:00",
"duration": 227,
"id": 5,
"parent": 3,
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/04.05 Sleazy.flac",
"size": 27938750,
"suffix": "flac",
"title": "Sleazy",
"track": 4,
"discNumber": 1,
"type": "music"
},
{
"album": "Snake Charmer",
"albumId": 3,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"artistId": 1,
"bitRate": 884,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "2019-07-08T21:49:40.983301328+01:00",
"duration": 418,
"id": 4,
"parent": 3,
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/05.05 Snake Charmer (reprise).flac",
"size": 46427922,
"suffix": "flac",
"title": "Snake Charmer (reprise)",
"track": 5,
"discNumber": 1,
"type": "music"
}
]
}
}
}

View File

@@ -0,0 +1,10 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"album": {
"id": 2,
"created": "2019-05-16T22:10:21+01:00"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"artist": {
"id": 1,
"name": "Jah Wobble, The Edge & Holger Czukay",
"album": [
{
"id": 3,
"coverArt": 3,
"artistId": 1,
"artist": "Jah Wobble, The Edge & Holger Czukay",
"name": "Snake Charmer",
"created": "2019-05-16T22:10:52+01:00"
}
]
}
}
}

View File

@@ -0,0 +1,28 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"artist": {
"id": 3,
"name": "13th Floor Elevators",
"album": [
{
"id": 8,
"coverArt": 8,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "Easter Everywhere",
"created": "2019-06-13T12:57:28.850090338+01:00"
},
{
"id": 9,
"coverArt": 9,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"created": "2019-06-13T12:57:24.306717554+01:00"
}
]
}
}
}

View File

@@ -0,0 +1,27 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"artist": {
"id": 2,
"name": "A Certain Ratio",
"album": [
{
"id": 5,
"coverArt": 5,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "The Graveyard and the Ballroom",
"created": "2019-06-05T17:46:37.675917974+01:00"
},
{
"id": 6,
"artistId": 2,
"artist": "A Certain Ratio",
"name": "To Each...",
"created": "2019-05-23T15:12:02.921473302+01:00"
}
]
}
}
}

View File

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

View File

@@ -0,0 +1,76 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"indexes": {
"lastModified": 0,
"index": [
{
"name": "#",
"artist": [
{
"id": 7,
"name": "13th Floor Lowervators",
"albumCount": 2
},
{
"id": 10,
"name": "___Anika",
"albumCount": 2
}
]
},
{
"name": "a",
"artist": [
{
"id": 4,
"name": "A Certain Ratio",
"albumCount": 2
}
]
},
{
"name": "c",
"artist": [
{
"id": 20,
"name": "Captain Beefheart",
"albumCount": 1
}
]
},
{
"name": "j",
"artist": [
{
"id": 2,
"name": "Jah Wobble, The Edge, Holger Czukay",
"albumCount": 1
}
]
},
{
"name": "s",
"artist": [
{
"id": 15,
"name": "Swell Maps",
"albumCount": 2
}
]
},
{
"name": "t",
"artist": [
{
"id": 18,
"name": "Ten Years After",
"albumCount": 1
}
]
}
]
}
}
}

View File

@@ -0,0 +1,103 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"directory": {
"id": 3,
"parent": 2,
"name": "(1983) Snake Charmer",
"child": [
{
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 882,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "0001-01-01T00:00:00Z",
"duration": 372,
"id": 1,
"parent": 3,
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/01.05 Snake Charmer.flac",
"size": 41274185,
"suffix": "flac",
"title": "Snake Charmer",
"track": 1,
"discNumber": 1,
"type": "music"
},
{
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 814,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "0001-01-01T00:00:00Z",
"duration": 523,
"id": 3,
"parent": 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"
},
{
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 745,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "0001-01-01T00:00:00Z",
"duration": 331,
"id": 2,
"parent": 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"
},
{
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 976,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "0001-01-01T00:00:00Z",
"duration": 227,
"id": 5,
"parent": 3,
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/04.05 Sleazy.flac",
"size": 27938750,
"suffix": "flac",
"title": "Sleazy",
"track": 4,
"discNumber": 1,
"type": "music"
},
{
"album": "(1983) Snake Charmer",
"artist": "Jah Wobble, The Edge & Holger Czukay",
"bitRate": 884,
"contentType": "audio/x-flac",
"coverArt": 3,
"created": "0001-01-01T00:00:00Z",
"duration": 418,
"id": 4,
"parent": 3,
"path": "Jah Wobble, The Edge, Holger Czukay/(1983) Snake Charmer/05.05 Snake Charmer (reprise).flac",
"size": 46427922,
"suffix": "flac",
"title": "Snake Charmer (reprise)",
"track": 5,
"discNumber": 1,
"type": "music"
}
]
}
}
}

View File

@@ -0,0 +1,21 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"directory": {
"id": 2,
"parent": 1,
"name": "Jah Wobble, The Edge, Holger Czukay",
"child": [
{
"coverArt": 3,
"created": "0001-01-01T00:00:00Z",
"id": 3,
"isDir": true,
"parent": 2,
"title": "(1983) Snake Charmer"
}
]
}
}
}

View File

@@ -0,0 +1,24 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"searchResult3": {
"artist": [
{
"id": 3,
"name": "13th Floor Elevators"
}
],
"album": [
{
"id": 9,
"coverArt": 9,
"artistId": 3,
"artist": "13th Floor Elevators",
"name": "The Psychedelic Sounds of the 13th Floor Elevators",
"created": "2019-06-13T12:57:24.306717554+01:00"
}
]
}
}
}

View File

@@ -0,0 +1,24 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"searchResult3": {
"artist": [
{
"id": 4,
"name": "Anikas"
}
],
"album": [
{
"id": 13,
"coverArt": 13,
"artistId": 4,
"artist": "Anikas",
"name": "Anika",
"created": "2019-05-23T15:12:02.921473302+01:00"
}
]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"searchResult3": {
"artist": [
{
"id": 2,
"name": "A Certain Ratio"
}
]
}
}
}

View File

@@ -0,0 +1,135 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"searchResult2": {
"artist": [
{
"id": 7,
"parent": 1,
"name": "13th Floor Lowervators"
}
],
"album": [
{
"coverArt": 9,
"created": "0001-01-01T00:00:00Z",
"id": 9,
"isDir": true,
"parent": 7,
"title": "(1966) The Psychedelic Sounds of the 13th Floor Elevators"
}
],
"song": [
{
"album": "(1994) The Graveyard and the Ballroom",
"artist": "A Certain Ratio",
"bitRate": 894,
"contentType": "audio/x-flac",
"coverArt": 5,
"created": "0001-01-01T00:00:00Z",
"duration": 332,
"id": 6,
"parent": 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"
},
{
"album": "(1966) The Psychedelic Sounds of the 13th Floor Elevators",
"artist": "13th Floor Elevators",
"bitRate": 244,
"contentType": "audio/mpeg",
"coverArt": 9,
"created": "0001-01-01T00:00:00Z",
"duration": 154,
"id": 40,
"parent": 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"
},
{
"album": "(1980) Jane From Occupied Europe",
"artist": "Swell Maps",
"bitRate": 1204,
"contentType": "audio/x-flac",
"coverArt": 16,
"created": "0001-01-01T00:00:00Z",
"duration": 220,
"id": 76,
"parent": 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"
},
{
"album": "(1979) A Trip to Marineville",
"artist": "Swell Maps",
"bitRate": 295,
"contentType": "audio/mpeg",
"coverArt": 17,
"created": "0001-01-01T00:00:00Z",
"duration": 463,
"id": 93,
"parent": 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"
},
{
"album": "(1967) Ten Years After",
"artist": "Ten Years After",
"bitRate": 192,
"contentType": "audio/ogg",
"coverArt": 19,
"created": "0001-01-01T00:00:00Z",
"duration": 433,
"id": 107,
"parent": 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"
},
{
"album": "(1970) Lick My Decals Off, Bitch",
"artist": "Captain Beefheart & His Magic Band",
"bitRate": 160,
"contentType": "audio/mpeg",
"coverArt": 21,
"created": "0001-01-01T00:00:00Z",
"duration": 152,
"id": 129,
"parent": 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,25 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.9.0",
"searchResult2": {
"artist": [
{
"id": 10,
"parent": 1,
"name": "___Anika"
}
],
"album": [
{
"coverArt": 13,
"created": "0001-01-01T00:00:00Z",
"id": 13,
"isDir": true,
"parent": 12,
"title": "(2010) Anika"
}
]
}
}
}

View File

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