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 0000000..7ae6f6a Binary files /dev/null and b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1500717849_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts differ 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 +}