Subsonic: internet radio, chat, bookmarks, play queue, scanner status endpoints
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package subsonic
|
||||
|
||||
import "git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
|
||||
func init() {
|
||||
register("getBookmarks", handleGetBookmarks)
|
||||
register("createBookmark", handleCreateBookmark)
|
||||
register("deleteBookmark", handleDeleteBookmark)
|
||||
}
|
||||
|
||||
func handleGetBookmarks(rc *Context) *apiError {
|
||||
db := rc.Server.DB
|
||||
bookmarks, err := database.ListBookmarks(db, rc.User.ID)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
out := &Bookmarks{}
|
||||
for _, b := range bookmarks {
|
||||
song, serr := database.GetSong(db, b.SongID)
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
mf, merr := database.GetMediaFile(db, song.MediaFileID)
|
||||
if merr != nil {
|
||||
continue
|
||||
}
|
||||
out.Bookmark = append(out.Bookmark, Bookmark{
|
||||
Position: int64(b.PositionMs),
|
||||
Username: rc.User.Username,
|
||||
Comment: b.Comment,
|
||||
Created: isoTime(b.CreatedAt),
|
||||
Changed: isoTime(b.UpdatedAt),
|
||||
Entry: childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID),
|
||||
})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.Bookmarks = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCreateBookmark(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
position, perr := rc.Params.RequiredInt("position")
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
comment := rc.Params.Get("comment")
|
||||
if _, err := database.GetSong(rc.Server.DB, id); err != nil {
|
||||
return errNotFound("Song")
|
||||
}
|
||||
if err := database.SetBookmark(rc.Server.DB, rc.User.ID, id, position, comment); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleDeleteBookmark(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
if err := database.DeleteBookmark(rc.Server.DB, rc.User.ID, id); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:1
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:1.023322,
|
||||
seg00000.ts
|
||||
#EXT-X-ENDLIST
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("getChatMessages", handleGetChatMessages)
|
||||
register("addChatMessage", handleAddChatMessage)
|
||||
}
|
||||
|
||||
func handleGetChatMessages(rc *Context) *apiError {
|
||||
since := time.Time{}
|
||||
if ms := rc.Params.Int64("since", 0); ms > 0 {
|
||||
since = time.UnixMilli(ms)
|
||||
}
|
||||
messages, err := database.ListChatMessagesSince(rc.Server.DB, since)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
out := &ChatMessages{}
|
||||
for _, m := range messages {
|
||||
out.ChatMessage = append(out.ChatMessage, ChatMessage{
|
||||
Username: m.Username,
|
||||
Time: m.CreatedAt.UnixMilli(),
|
||||
Message: m.Message,
|
||||
})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.ChatMessages = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleAddChatMessage(rc *Context) *apiError {
|
||||
message, aerr := rc.Params.Required("message")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
if err := database.AddChatMessage(rc.Server.DB, rc.User.ID, message); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInternetRadioLifecycle(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "admin", "password123", true)
|
||||
|
||||
q := baseParams("admin", "password123")
|
||||
q.Set("name", "Test Station")
|
||||
q.Set("streamUrl", "http://example.com/stream.mp3")
|
||||
resp := getPath(t, srv.URL, "/rest/createInternetRadioStation.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "ok" {
|
||||
t.Fatalf("createInternetRadioStation failed: %+v", x)
|
||||
}
|
||||
|
||||
q2 := baseParams("admin", "password123")
|
||||
q2.Set("f", "json")
|
||||
resp = getPath(t, srv.URL, "/rest/getInternetRadioStations.view", q2)
|
||||
j := decodeJSON(t, resp)
|
||||
stations := j["internetRadioStations"].(map[string]any)["internetRadioStation"].([]any)
|
||||
if len(stations) != 1 {
|
||||
t.Fatalf("stations = %v, want 1", stations)
|
||||
}
|
||||
stationID := stations[0].(map[string]any)["id"].(string)
|
||||
|
||||
q3 := baseParams("admin", "password123")
|
||||
q3.Set("id", stationID)
|
||||
resp = getPath(t, srv.URL, "/rest/deleteInternetRadioStation.view", q3)
|
||||
x = decodeXML(t, resp)
|
||||
if x.Status != "ok" {
|
||||
t.Fatalf("deleteInternetRadioStation failed: %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternetRadioRequiresAdmin(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("name", "X")
|
||||
q.Set("streamUrl", "http://example.com/x.mp3")
|
||||
resp := getPath(t, srv.URL, "/rest/createInternetRadioStation.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
|
||||
t.Fatalf("expected error 50 for non-admin, got %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessages(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("message", "hello world")
|
||||
resp := getPath(t, srv.URL, "/rest/addChatMessage.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "ok" {
|
||||
t.Fatalf("addChatMessage failed: %+v", x)
|
||||
}
|
||||
|
||||
q2 := baseParams("alice", "password123")
|
||||
q2.Set("f", "json")
|
||||
resp = getPath(t, srv.URL, "/rest/getChatMessages.view", q2)
|
||||
j := decodeJSON(t, resp)
|
||||
messages := j["chatMessages"].(map[string]any)["chatMessage"].([]any)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages = %v, want 1", messages)
|
||||
}
|
||||
msg := messages[0].(map[string]any)
|
||||
if msg["message"] != "hello world" || msg["username"] != "alice" {
|
||||
t.Fatalf("message = %v, want hello world from alice", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookmarkLifecycle(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", song.ID)
|
||||
q.Set("position", "5000")
|
||||
q.Set("comment", "resume here")
|
||||
resp := getPath(t, srv.URL, "/rest/createBookmark.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "ok" {
|
||||
t.Fatalf("createBookmark failed: %+v", x)
|
||||
}
|
||||
|
||||
q2 := baseParams("alice", "password123")
|
||||
q2.Set("f", "json")
|
||||
resp = getPath(t, srv.URL, "/rest/getBookmarks.view", q2)
|
||||
j := decodeJSON(t, resp)
|
||||
bookmarks := j["bookmarks"].(map[string]any)["bookmark"].([]any)
|
||||
if len(bookmarks) != 1 {
|
||||
t.Fatalf("bookmarks = %v, want 1", bookmarks)
|
||||
}
|
||||
bm := bookmarks[0].(map[string]any)
|
||||
if bm["comment"] != "resume here" {
|
||||
t.Fatalf("bookmark comment = %v", bm["comment"])
|
||||
}
|
||||
|
||||
q3 := baseParams("alice", "password123")
|
||||
q3.Set("id", song.ID)
|
||||
resp = getPath(t, srv.URL, "/rest/deleteBookmark.view", q3)
|
||||
x = decodeXML(t, resp)
|
||||
if x.Status != "ok" {
|
||||
t.Fatalf("deleteBookmark failed: %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayQueueRoundTrip(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
seedLibrary(t, s)
|
||||
song := firstSong(t, s)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Add("id", song.ID)
|
||||
q.Set("current", song.ID)
|
||||
q.Set("position", "1234")
|
||||
resp := getPath(t, srv.URL, "/rest/savePlayQueue.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "ok" {
|
||||
t.Fatalf("savePlayQueue failed: %+v", x)
|
||||
}
|
||||
|
||||
q2 := baseParams("alice", "password123")
|
||||
q2.Set("f", "json")
|
||||
resp = getPath(t, srv.URL, "/rest/getPlayQueue.view", q2)
|
||||
j := decodeJSON(t, resp)
|
||||
pq := j["playQueue"].(map[string]any)
|
||||
if pq["current"] != song.ID {
|
||||
t.Fatalf("playQueue.current = %v, want %v", pq["current"], song.ID)
|
||||
}
|
||||
entries, ok := pq["entry"].([]any)
|
||||
if !ok || len(entries) != 1 {
|
||||
t.Fatalf("playQueue entries = %v, want 1", pq["entry"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlayQueueEmpty(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv.URL, "/rest/getPlayQueue.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
if j["status"] != "ok" {
|
||||
t.Fatalf("empty getPlayQueue should still succeed, got %v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanStatusAndStartScan(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "admin", "password123", true)
|
||||
seedLibrary(t, s)
|
||||
|
||||
q := baseParams("admin", "password123")
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv.URL, "/rest/getScanStatus.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
status := j["scanStatus"].(map[string]any)
|
||||
if status["scanning"] != false {
|
||||
t.Fatalf("scanning = %v, want false when idle", status["scanning"])
|
||||
}
|
||||
if count, ok := status["count"].(float64); !ok || count < 2 {
|
||||
t.Fatalf("count = %v, want >= 2 (seeded songs)", status["count"])
|
||||
}
|
||||
|
||||
resp = getPath(t, srv.URL, "/rest/startScan.view", q)
|
||||
j = decodeJSON(t, resp)
|
||||
if j["status"] != "ok" {
|
||||
t.Fatalf("startScan failed: %v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartScanRequiresAdmin(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
resp := getPath(t, srv.URL, "/rest/startScan.view", baseParams("alice", "password123"))
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
|
||||
t.Fatalf("expected error 50 for non-admin startScan, got %+v", x)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("getPlayQueue", handleGetPlayQueue)
|
||||
register("savePlayQueue", handleSavePlayQueue)
|
||||
}
|
||||
|
||||
func handleGetPlayQueue(rc *Context) *apiError {
|
||||
db := rc.Server.DB
|
||||
q, err := database.GetPlayQueue(db, rc.User.ID)
|
||||
if err == database.ErrNotFound {
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
|
||||
var ids []string
|
||||
if uerr := json.Unmarshal([]byte(q.SongIDsJSON), &ids); uerr != nil {
|
||||
return newError(ErrGeneric, "corrupt saved play queue")
|
||||
}
|
||||
|
||||
out := &PlayQueue{
|
||||
Position: int64(q.PositionMs),
|
||||
Username: rc.User.Username,
|
||||
Changed: isoTime(q.UpdatedAt),
|
||||
ChangedBy: q.Client,
|
||||
}
|
||||
for i, id := range ids {
|
||||
song, serr := database.GetSong(db, id)
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
mf, merr := database.GetMediaFile(db, song.MediaFileID)
|
||||
if merr != nil {
|
||||
continue
|
||||
}
|
||||
out.Entry = append(out.Entry, childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID))
|
||||
if i == q.CurrentIndex {
|
||||
out.Current = song.ID
|
||||
}
|
||||
}
|
||||
|
||||
rc.Response = newResponse()
|
||||
rc.Response.PlayQueue = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSavePlayQueue(rc *Context) *apiError {
|
||||
ids := rc.Params.GetAll("id")
|
||||
current := rc.Params.Get("current")
|
||||
position := rc.Params.Int("position", 0)
|
||||
client := rc.Params.Get("c")
|
||||
|
||||
currentIndex := 0
|
||||
for i, id := range ids {
|
||||
if id == current {
|
||||
currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
if err := database.SavePlayQueue(rc.Server.DB, rc.User.ID, string(encoded), currentIndex, position, client); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package subsonic
|
||||
|
||||
import "git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
|
||||
func init() {
|
||||
register("getInternetRadioStations", handleGetInternetRadioStations)
|
||||
register("createInternetRadioStation", handleCreateInternetRadioStation)
|
||||
register("updateInternetRadioStation", handleUpdateInternetRadioStation)
|
||||
register("deleteInternetRadioStation", handleDeleteInternetRadioStation)
|
||||
}
|
||||
|
||||
func handleGetInternetRadioStations(rc *Context) *apiError {
|
||||
stations, err := database.ListRadioStations(rc.Server.DB)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
out := &InternetRadioStations{}
|
||||
for _, s := range stations {
|
||||
out.InternetRadioStation = append(out.InternetRadioStation, InternetRadioStation{
|
||||
ID: s.ID, Name: s.Name, StreamURL: s.StreamURL, HomepageURL: s.HomepageURL,
|
||||
})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.InternetRadioStations = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCreateInternetRadioStation(rc *Context) *apiError {
|
||||
if !rc.User.IsAdmin {
|
||||
return errUnauthorized("managing internet radio stations")
|
||||
}
|
||||
streamURL, aerr := rc.Params.Required("streamUrl")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
name, aerr := rc.Params.Required("name")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
homepage := rc.Params.Get("homepageUrl")
|
||||
if _, err := database.CreateRadioStation(rc.Server.DB, name, streamURL, homepage); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleUpdateInternetRadioStation(rc *Context) *apiError {
|
||||
if !rc.User.IsAdmin {
|
||||
return errUnauthorized("managing internet radio stations")
|
||||
}
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
streamURL, aerr := rc.Params.Required("streamUrl")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
name, aerr := rc.Params.Required("name")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
homepage := rc.Params.Get("homepageUrl")
|
||||
if _, err := database.GetRadioStation(rc.Server.DB, id); err != nil {
|
||||
return errNotFound("Internet radio station")
|
||||
}
|
||||
if err := database.UpdateRadioStation(rc.Server.DB, id, name, streamURL, homepage); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleDeleteInternetRadioStation(rc *Context) *apiError {
|
||||
if !rc.User.IsAdmin {
|
||||
return errUnauthorized("managing internet radio stations")
|
||||
}
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
if _, err := database.GetRadioStation(rc.Server.DB, id); err != nil {
|
||||
return errNotFound("Internet radio station")
|
||||
}
|
||||
if err := database.DeleteRadioStation(rc.Server.DB, id); err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("getScanStatus", handleGetScanStatus)
|
||||
register("startScan", handleStartScan)
|
||||
}
|
||||
|
||||
func scanStatus(rc *Context) (*ScanStatus, *apiError) {
|
||||
db := rc.Server.DB
|
||||
scanning := rc.Server.Scanner.IsAnyScanning()
|
||||
var count int64
|
||||
var err error
|
||||
if scanning {
|
||||
count, err = database.TotalFilesSeenAcrossScans(db)
|
||||
} else {
|
||||
count, err = database.TotalMediaFileCount(db)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, newError(ErrGeneric, err.Error())
|
||||
}
|
||||
return &ScanStatus{Scanning: scanning, Count: count}, nil
|
||||
}
|
||||
|
||||
func handleGetScanStatus(rc *Context) *apiError {
|
||||
status, aerr := scanStatus(rc)
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.ScanStatus = status
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleStartScan(rc *Context) *apiError {
|
||||
if !rc.User.IsAdmin {
|
||||
return errUnauthorized("starting a library scan")
|
||||
}
|
||||
if !rc.Server.Scanner.IsAnyScanning() {
|
||||
go func() {
|
||||
_ = rc.Server.Scanner.ScanAllEnabled(context.Background())
|
||||
}()
|
||||
}
|
||||
status, aerr := scanStatus(rc)
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.ScanStatus = status
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user