feat: add browse api and subsonic read endpoints
Expose internal browse endpoints for artists, albums, tracks, and search using the SQLite-backed library service. Add Subsonic-compatible getArtist, getAlbum, getSong, and stream.view handlers by mapping the same persistence layer into lightweight response envelopes.
This commit is contained in:
@@ -51,7 +51,12 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
private.Use(application.requireAuth)
|
||||
private.Get("/me", application.me)
|
||||
private.Get("/home", application.home)
|
||||
private.Get("/artists", application.artists)
|
||||
private.Get("/artists/{id}", application.artistByID)
|
||||
private.Get("/albums/{id}", application.albumByID)
|
||||
private.Get("/tracks", application.tracks)
|
||||
private.Get("/tracks/{id}", application.trackByID)
|
||||
private.Get("/search", application.search)
|
||||
private.Get("/admin/scan-status", application.scanStatus)
|
||||
private.Post("/admin/scan", application.scanLibrary)
|
||||
})
|
||||
@@ -70,10 +75,14 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
rest.Group(func(authed chi.Router) {
|
||||
authed.Use(application.requireSubsonicAuth)
|
||||
authed.Get("/getArtists.view", application.subsonicArtists)
|
||||
authed.Get("/getArtist.view", application.subsonicArtistByID)
|
||||
authed.Get("/getAlbum.view", application.subsonicAlbumByID)
|
||||
authed.Get("/getSong.view", application.subsonicSongByID)
|
||||
authed.Get("/getRandomSongs.view", application.subsonicRandomSongs)
|
||||
authed.Get("/getScanStatus.view", application.subsonicScanStatus)
|
||||
authed.Get("/startScan.view", application.subsonicStartScan)
|
||||
authed.Get("/getCoverArt.view", application.subsonicCoverArt)
|
||||
authed.Get("/stream.view", application.subsonicStream)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,6 +130,41 @@ func (a app) home(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, home)
|
||||
}
|
||||
|
||||
func (a app) artists(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := a.library.Artists(r.Context(), 500)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load artists"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a app) artistByID(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.library.ArtistByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "artist not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load artist"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "album not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a app) tracks(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := a.library.Tracks(r.Context(), 200)
|
||||
if err != nil {
|
||||
@@ -130,6 +174,34 @@ func (a app) tracks(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a app) trackByID(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a app) search(w http.ResponseWriter, r *http.Request) {
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if query == "" {
|
||||
writeJSON(w, http.StatusOK, library.SearchResults{})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := a.library.Search(r.Context(), query, 20)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "search failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
|
||||
func (a app) subsonicArtists(w http.ResponseWriter, r *http.Request) {
|
||||
artists, err := a.library.Artists(r.Context(), 1000)
|
||||
if err != nil {
|
||||
@@ -148,6 +220,45 @@ func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks))
|
||||
}
|
||||
|
||||
func (a app) subsonicArtistByID(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.library.ArtistByID(r.Context(), r.URL.Query().Get("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "artist not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load artist"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, subsonic.ArtistResponse(item))
|
||||
}
|
||||
|
||||
func (a app) subsonicAlbumByID(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.library.AlbumByID(r.Context(), r.URL.Query().Get("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "album not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load album"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, subsonic.AlbumResponse(item))
|
||||
}
|
||||
|
||||
func (a app) subsonicSongByID(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.library.TrackByID(r.Context(), r.URL.Query().Get("id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "song not found"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load song"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, subsonic.SongResponse(item))
|
||||
}
|
||||
|
||||
func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := a.scanner.Scan(r.Context())
|
||||
if err != nil {
|
||||
@@ -183,28 +294,7 @@ func (a app) streamTrack(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
track, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(track.FilePath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "audio file not available"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to stat file"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", track.ContentType)
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||
a.serveTrackByID(w, r, chi.URLParam(r, "id"))
|
||||
}
|
||||
|
||||
func (a app) subsonicScanStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -223,6 +313,10 @@ func (a app) subsonicCoverArt(w http.ResponseWriter, r *http.Request) {
|
||||
a.serveCoverArtByID(w, r, r.URL.Query().Get("id"))
|
||||
}
|
||||
|
||||
func (a app) subsonicStream(w http.ResponseWriter, r *http.Request) {
|
||||
a.serveTrackByID(w, r, r.URL.Query().Get("id"))
|
||||
}
|
||||
|
||||
func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string) {
|
||||
path, err := a.library.CoverArtPathByEntityID(r.Context(), id)
|
||||
if err != nil {
|
||||
@@ -251,6 +345,34 @@ func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
func (a app) serveTrackByID(w http.ResponseWriter, r *http.Request, id string) {
|
||||
track, err := a.library.TrackByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(track.FilePath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "audio file not available"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to stat file"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", track.ContentType)
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
func detectImageContentType(path string) string {
|
||||
lower := strings.ToLower(path)
|
||||
switch {
|
||||
|
||||
Reference in New Issue
Block a user