Subsonic: internet radio, chat, bookmarks, play queue, scanner status endpoints
This commit is contained in:
@@ -144,6 +144,8 @@ type Bookmark struct {
|
|||||||
SongID string
|
SongID string
|
||||||
PositionMs int
|
PositionMs int
|
||||||
Comment string
|
Comment string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetBookmark(db *sql.DB, userID, songID string, positionMs int, comment string) error {
|
func SetBookmark(db *sql.DB, userID, songID string, positionMs int, comment string) error {
|
||||||
@@ -159,7 +161,7 @@ func DeleteBookmark(db *sql.DB, userID, songID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListBookmarks(db *sql.DB, userID string) ([]*Bookmark, error) {
|
func ListBookmarks(db *sql.DB, userID string) ([]*Bookmark, error) {
|
||||||
rows, err := db.Query(`SELECT id, song_id, position_ms, comment FROM bookmarks WHERE user_id = ? ORDER BY updated_at DESC`, userID)
|
rows, err := db.Query(`SELECT id, song_id, position_ms, comment, created_at, updated_at FROM bookmarks WHERE user_id = ? ORDER BY updated_at DESC`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,9 +169,12 @@ func ListBookmarks(db *sql.DB, userID string) ([]*Bookmark, error) {
|
|||||||
var out []*Bookmark
|
var out []*Bookmark
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var b Bookmark
|
var b Bookmark
|
||||||
if err := rows.Scan(&b.ID, &b.SongID, &b.PositionMs, &b.Comment); err != nil {
|
var createdAt, updatedAt string
|
||||||
|
if err := rows.Scan(&b.ID, &b.SongID, &b.PositionMs, &b.Comment, &createdAt, &updatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
b.CreatedAt, _ = parseTime(createdAt)
|
||||||
|
b.UpdatedAt, _ = parseTime(updatedAt)
|
||||||
out = append(out, &b)
|
out = append(out, &b)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatMessage struct {
|
||||||
|
Username string
|
||||||
|
Message string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddChatMessage(db *sql.DB, userID, message string) error {
|
||||||
|
_, err := db.Exec(`INSERT INTO chat_messages (id, user_id, message) VALUES (?, ?, ?)`, idgen.New("cm"), userID, message)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChatMessagesSince returns chat messages newer than since (pass the
|
||||||
|
// zero time for all messages), oldest first, capped at 500 — a chat
|
||||||
|
// room does not need unbounded history in one response.
|
||||||
|
func ListChatMessagesSince(db *sql.DB, since time.Time) ([]ChatMessage, error) {
|
||||||
|
rows, err := db.Query(`SELECT u.username, c.message, c.created_at FROM chat_messages c
|
||||||
|
JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE c.created_at > ? ORDER BY c.created_at ASC LIMIT 500`, since.UTC().Format(timeLayout))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var m ChatMessage
|
||||||
|
var createdAt string
|
||||||
|
if err := rows.Scan(&m.Username, &m.Message, &createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.CreatedAt, _ = parseTime(createdAt)
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RadioStation struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
StreamURL string
|
||||||
|
HomepageURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRadioStation(db *sql.DB, name, streamURL, homepageURL string) (*RadioStation, error) {
|
||||||
|
id := idgen.New("rs")
|
||||||
|
_, err := db.Exec(`INSERT INTO internet_radio_stations (id, name, stream_url, homepage_url) VALUES (?, ?, ?, ?)`,
|
||||||
|
id, name, streamURL, homepageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return GetRadioStation(db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRadioStation(db *sql.DB, id string) (*RadioStation, error) {
|
||||||
|
var r RadioStation
|
||||||
|
err := db.QueryRow(`SELECT id, name, stream_url, homepage_url FROM internet_radio_stations WHERE id = ?`, id).
|
||||||
|
Scan(&r.ID, &r.Name, &r.StreamURL, &r.HomepageURL)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListRadioStations(db *sql.DB) ([]*RadioStation, error) {
|
||||||
|
rows, err := db.Query(`SELECT id, name, stream_url, homepage_url FROM internet_radio_stations ORDER BY name COLLATE NOCASE`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*RadioStation
|
||||||
|
for rows.Next() {
|
||||||
|
var r RadioStation
|
||||||
|
if err := rows.Scan(&r.ID, &r.Name, &r.StreamURL, &r.HomepageURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, &r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateRadioStation(db *sql.DB, id, name, streamURL, homepageURL string) error {
|
||||||
|
_, err := db.Exec(`UPDATE internet_radio_stations SET name = ?, stream_url = ?, homepage_url = ? WHERE id = ?`,
|
||||||
|
name, streamURL, homepageURL, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteRadioStation(db *sql.DB, id string) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM internet_radio_stations WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -259,3 +259,19 @@ func FindSongByTitleArtist(db *sql.DB, title, artist string) (*Song, error) {
|
|||||||
}
|
}
|
||||||
return GetSong(db, id)
|
return GetSong(db, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TotalMediaFileCount returns how many (non-missing) media files are
|
||||||
|
// indexed across all folders, for getScanStatus's idle "count".
|
||||||
|
func TotalMediaFileCount(db *sql.DB) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := db.QueryRow(`SELECT COUNT(*) FROM media_files WHERE missing = 0`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalFilesSeenAcrossScans sums files_seen for folders currently mid-
|
||||||
|
// scan, for getScanStatus's in-progress "count".
|
||||||
|
func TotalFilesSeenAcrossScans(db *sql.DB) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := db.QueryRow(`SELECT COALESCE(SUM(files_seen), 0) FROM scan_state WHERE status = 'scanning'`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -259,6 +259,13 @@ func (s *Scanner) IsScanning(folderID string) bool {
|
|||||||
return s.scanning[folderID]
|
return s.scanning[folderID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsAnyScanning reports whether any folder is currently being scanned.
|
||||||
|
func (s *Scanner) IsAnyScanning() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.scanning) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// ScanAllEnabled scans every enabled music folder sequentially (folders
|
// ScanAllEnabled scans every enabled music folder sequentially (folders
|
||||||
// run one at a time; files within a folder run concurrently via the
|
// run one at a time; files within a folder run concurrently via the
|
||||||
// worker pool above).
|
// worker pool above).
|
||||||
|
|||||||
@@ -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