Subsonic: internet radio, chat, bookmarks, play queue, scanner status endpoints
This commit is contained in:
@@ -144,6 +144,8 @@ type Bookmark struct {
|
||||
SongID string
|
||||
PositionMs int
|
||||
Comment string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,9 +169,12 @@ func ListBookmarks(db *sql.DB, userID string) ([]*Bookmark, error) {
|
||||
var out []*Bookmark
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
b.CreatedAt, _ = parseTime(createdAt)
|
||||
b.UpdatedAt, _ = parseTime(updatedAt)
|
||||
out = append(out, &b)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
|
||||
// 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
|
||||
// run one at a time; files within a folder run concurrently via the
|
||||
// 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