Subsonic: internet radio, chat, bookmarks, play queue, scanner status endpoints

This commit is contained in:
2026-08-23 00:21:13 -05:00
parent c6e0131984
commit d92a25b141
13 changed files with 682 additions and 2 deletions
+7 -2
View File
@@ -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()
+43
View File
@@ -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()
}
+66
View File
@@ -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
}
+16
View File
@@ -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
}
+7
View File
@@ -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).
+71
View File
@@ -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
}
@@ -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
+46
View File
@@ -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
}
+193
View File
@@ -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)
}
}
+79
View File
@@ -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
}
+91
View File
@@ -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
}
+56
View File
@@ -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
}