From d92a25b1410448bffcb53665bee078462aeedbcc Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:21:13 -0500 Subject: [PATCH] Subsonic: internet radio, chat, bookmarks, play queue, scanner status endpoints --- internal/database/activity.go | 9 +- internal/database/chat.go | 43 ++++ internal/database/radio.go | 66 ++++++ internal/database/subsonic_helpers2.go | 16 ++ internal/scanner/scanner.go | 7 + internal/subsonic/bookmarks.go | 71 +++++++ .../index.m3u8 | 7 + .../seg00000.ts | Bin 0 -> 19928 bytes internal/subsonic/chat.go | 46 +++++ internal/subsonic/misc_endpoints_test.go | 193 ++++++++++++++++++ internal/subsonic/playqueue.go | 79 +++++++ internal/subsonic/radio.go | 91 +++++++++ internal/subsonic/scanner_status.go | 56 +++++ 13 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 internal/database/chat.go create mode 100644 internal/database/radio.go create mode 100644 internal/subsonic/bookmarks.go create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1500717849_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1500717849_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts create mode 100644 internal/subsonic/chat.go create mode 100644 internal/subsonic/misc_endpoints_test.go create mode 100644 internal/subsonic/playqueue.go create mode 100644 internal/subsonic/radio.go create mode 100644 internal/subsonic/scanner_status.go diff --git a/internal/database/activity.go b/internal/database/activity.go index 45e2d8c..e031667 100644 --- a/internal/database/activity.go +++ b/internal/database/activity.go @@ -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() diff --git a/internal/database/chat.go b/internal/database/chat.go new file mode 100644 index 0000000..d6cb1c6 --- /dev/null +++ b/internal/database/chat.go @@ -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() +} diff --git a/internal/database/radio.go b/internal/database/radio.go new file mode 100644 index 0000000..9ed33ed --- /dev/null +++ b/internal/database/radio.go @@ -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 +} diff --git a/internal/database/subsonic_helpers2.go b/internal/database/subsonic_helpers2.go index 7646381..1d482c8 100644 --- a/internal/database/subsonic_helpers2.go +++ b/internal/database/subsonic_helpers2.go @@ -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 +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index a40abc5..0f49b84 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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). diff --git a/internal/subsonic/bookmarks.go b/internal/subsonic/bookmarks.go new file mode 100644 index 0000000..08e2282 --- /dev/null +++ b/internal/subsonic/bookmarks.go @@ -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 +} diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1500717849_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1500717849_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1500717849_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 @@ -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 diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1500717849_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1500717849_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ae6f6a51d177346266335c5719da8f5f9308a2f GIT binary patch literal 19928 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 DHaouJ literal 0 HcmV?d00001 diff --git a/internal/subsonic/chat.go b/internal/subsonic/chat.go new file mode 100644 index 0000000..e72b6cd --- /dev/null +++ b/internal/subsonic/chat.go @@ -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 +} diff --git a/internal/subsonic/misc_endpoints_test.go b/internal/subsonic/misc_endpoints_test.go new file mode 100644 index 0000000..4bfc866 --- /dev/null +++ b/internal/subsonic/misc_endpoints_test.go @@ -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) + } +} diff --git a/internal/subsonic/playqueue.go b/internal/subsonic/playqueue.go new file mode 100644 index 0000000..a8301dd --- /dev/null +++ b/internal/subsonic/playqueue.go @@ -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 +} diff --git a/internal/subsonic/radio.go b/internal/subsonic/radio.go new file mode 100644 index 0000000..1c23d8c --- /dev/null +++ b/internal/subsonic/radio.go @@ -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 +} diff --git a/internal/subsonic/scanner_status.go b/internal/subsonic/scanner_status.go new file mode 100644 index 0000000..edc158f --- /dev/null +++ b/internal/subsonic/scanner_status.go @@ -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 +}