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.Use(application.requireAuth)
|
||||||
private.Get("/me", application.me)
|
private.Get("/me", application.me)
|
||||||
private.Get("/home", application.home)
|
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", application.tracks)
|
||||||
|
private.Get("/tracks/{id}", application.trackByID)
|
||||||
|
private.Get("/search", application.search)
|
||||||
private.Get("/admin/scan-status", application.scanStatus)
|
private.Get("/admin/scan-status", application.scanStatus)
|
||||||
private.Post("/admin/scan", application.scanLibrary)
|
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) {
|
rest.Group(func(authed chi.Router) {
|
||||||
authed.Use(application.requireSubsonicAuth)
|
authed.Use(application.requireSubsonicAuth)
|
||||||
authed.Get("/getArtists.view", application.subsonicArtists)
|
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("/getRandomSongs.view", application.subsonicRandomSongs)
|
||||||
authed.Get("/getScanStatus.view", application.subsonicScanStatus)
|
authed.Get("/getScanStatus.view", application.subsonicScanStatus)
|
||||||
authed.Get("/startScan.view", application.subsonicStartScan)
|
authed.Get("/startScan.view", application.subsonicStartScan)
|
||||||
authed.Get("/getCoverArt.view", application.subsonicCoverArt)
|
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)
|
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) {
|
func (a app) tracks(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := a.library.Tracks(r.Context(), 200)
|
items, err := a.library.Tracks(r.Context(), 200)
|
||||||
if err != nil {
|
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})
|
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) {
|
func (a app) subsonicArtists(w http.ResponseWriter, r *http.Request) {
|
||||||
artists, err := a.library.Artists(r.Context(), 1000)
|
artists, err := a.library.Artists(r.Context(), 1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -148,6 +220,45 @@ func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks))
|
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) {
|
func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) {
|
||||||
result, err := a.scanner.Scan(r.Context())
|
result, err := a.scanner.Scan(r.Context())
|
||||||
if err != nil {
|
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"})
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.serveTrackByID(w, r, chi.URLParam(r, "id"))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a app) subsonicScanStatus(w http.ResponseWriter, r *http.Request) {
|
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"))
|
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) {
|
func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
path, err := a.library.CoverArtPathByEntityID(r.Context(), id)
|
path, err := a.library.CoverArtPathByEntityID(r.Context(), id)
|
||||||
if err != nil {
|
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)
|
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 {
|
func detectImageContentType(path string) string {
|
||||||
lower := strings.ToLower(path)
|
lower := strings.ToLower(path)
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Artist struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AlbumCount int `json:"albumCount"`
|
AlbumCount int `json:"albumCount"`
|
||||||
|
CoverArtID string `json:"coverArtId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
@@ -44,6 +45,22 @@ type HomePayload struct {
|
|||||||
Artists []Artist `json:"artists"`
|
Artists []Artist `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArtistDetail struct {
|
||||||
|
Artist
|
||||||
|
Albums []Album `json:"albums"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumDetail struct {
|
||||||
|
Album
|
||||||
|
Tracks []Track `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResults struct {
|
||||||
|
Artists []Artist `json:"artists"`
|
||||||
|
Albums []Album `json:"albums"`
|
||||||
|
Tracks []Track `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
@@ -73,6 +90,7 @@ func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
|||||||
rows, err := s.db.QueryContext(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT a.id, a.name, COUNT(al.id) AS album_count
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count
|
||||||
|
, COALESCE(a.cover_art_id, '')
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN albums al ON al.artist_id = a.id
|
LEFT JOIN albums al ON al.artist_id = a.id
|
||||||
GROUP BY a.id, a.name
|
GROUP BY a.id, a.name
|
||||||
@@ -88,7 +106,7 @@ func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
|||||||
var artists []Artist
|
var artists []Artist
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var artist Artist
|
var artist Artist
|
||||||
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount); err != nil {
|
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID); err != nil {
|
||||||
return nil, fmt.Errorf("scan artist: %w", err)
|
return nil, fmt.Errorf("scan artist: %w", err)
|
||||||
}
|
}
|
||||||
artists = append(artists, artist)
|
artists = append(artists, artist)
|
||||||
@@ -97,6 +115,34 @@ func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
|||||||
return artists, rows.Err()
|
return artists, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ArtistByID(ctx context.Context, id string) (ArtistDetail, error) {
|
||||||
|
var artist ArtistDetail
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count, COALESCE(a.cover_art_id, '')
|
||||||
|
FROM artists a
|
||||||
|
LEFT JOIN albums al ON al.artist_id = a.id
|
||||||
|
WHERE a.id = ?
|
||||||
|
GROUP BY a.id, a.name, a.cover_art_id`,
|
||||||
|
id,
|
||||||
|
).Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ArtistDetail{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return ArtistDetail{}, fmt.Errorf("query artist by id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, err := s.albumsByArtistID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return ArtistDetail{}, err
|
||||||
|
}
|
||||||
|
artist.Albums = albums
|
||||||
|
|
||||||
|
return artist, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) {
|
func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) {
|
||||||
rows, err := s.db.QueryContext(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -127,6 +173,44 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
|
|||||||
return albums, rows.Err()
|
return albums, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error) {
|
||||||
|
var album AlbumDetail
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0),
|
||||||
|
COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '')
|
||||||
|
FROM albums al
|
||||||
|
JOIN artists a ON a.id = al.artist_id
|
||||||
|
LEFT JOIN tracks t ON t.album_id = al.id
|
||||||
|
WHERE al.id = ?
|
||||||
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id`,
|
||||||
|
id,
|
||||||
|
).Scan(
|
||||||
|
&album.ID,
|
||||||
|
&album.ArtistID,
|
||||||
|
&album.ArtistName,
|
||||||
|
&album.Title,
|
||||||
|
&album.Year,
|
||||||
|
&album.TrackCount,
|
||||||
|
&album.CoverArtID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return AlbumDetail{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return AlbumDetail{}, fmt.Errorf("query album by id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := s.tracksByAlbumID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return AlbumDetail{}, err
|
||||||
|
}
|
||||||
|
album.Tracks = tracks
|
||||||
|
|
||||||
|
return album, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
|
func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
|
||||||
rows, err := s.db.QueryContext(
|
rows, err := s.db.QueryContext(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -194,12 +278,40 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
|
|||||||
&track.CoverArtID,
|
&track.CoverArtID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Track{}, ErrNotFound
|
||||||
|
}
|
||||||
return Track{}, fmt.Errorf("query track by id: %w", err)
|
return Track{}, fmt.Errorf("query track by id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Search(ctx context.Context, query string, limit int) (SearchResults, error) {
|
||||||
|
pattern := "%" + query + "%"
|
||||||
|
|
||||||
|
artists, err := s.searchArtists(ctx, pattern, limit)
|
||||||
|
if err != nil {
|
||||||
|
return SearchResults{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, err := s.searchAlbums(ctx, pattern, limit)
|
||||||
|
if err != nil {
|
||||||
|
return SearchResults{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := s.searchTracks(ctx, pattern, limit)
|
||||||
|
if err != nil {
|
||||||
|
return SearchResults{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchResults{
|
||||||
|
Artists: artists,
|
||||||
|
Albums: albums,
|
||||||
|
Tracks: tracks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string, error) {
|
func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string, error) {
|
||||||
var path string
|
var path string
|
||||||
|
|
||||||
@@ -231,3 +343,177 @@ func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string
|
|||||||
}
|
}
|
||||||
return "", fmt.Errorf("query track cover art: %w", err)
|
return "", fmt.Errorf("query track cover art: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Album, error) {
|
||||||
|
rows, err := s.db.QueryContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '')
|
||||||
|
FROM albums al
|
||||||
|
JOIN artists a ON a.id = al.artist_id
|
||||||
|
LEFT JOIN tracks t ON t.album_id = al.id
|
||||||
|
WHERE al.artist_id = ?
|
||||||
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
||||||
|
ORDER BY al.year DESC, al.title ASC`,
|
||||||
|
artistID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query albums by artist: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var albums []Album
|
||||||
|
for rows.Next() {
|
||||||
|
var album Album
|
||||||
|
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan album by artist: %w", err)
|
||||||
|
}
|
||||||
|
albums = append(albums, album)
|
||||||
|
}
|
||||||
|
|
||||||
|
return albums, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) tracksByAlbumID(ctx context.Context, albumID string) ([]Track, error) {
|
||||||
|
rows, err := s.db.QueryContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
||||||
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
||||||
|
FROM tracks t
|
||||||
|
JOIN artists a ON a.id = t.artist_id
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
|
WHERE t.album_id = ?
|
||||||
|
ORDER BY t.disc_number ASC, t.track_number ASC, t.title ASC`,
|
||||||
|
albumID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query tracks by album: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tracks []Track
|
||||||
|
for rows.Next() {
|
||||||
|
var track Track
|
||||||
|
if err := rows.Scan(
|
||||||
|
&track.ID,
|
||||||
|
&track.AlbumID,
|
||||||
|
&track.ArtistID,
|
||||||
|
&track.Title,
|
||||||
|
&track.ArtistName,
|
||||||
|
&track.AlbumTitle,
|
||||||
|
&track.TrackNumber,
|
||||||
|
&track.DurationSecs,
|
||||||
|
&track.FilePath,
|
||||||
|
&track.ContentType,
|
||||||
|
&track.CoverArtID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan tracks by album: %w", err)
|
||||||
|
}
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) searchArtists(ctx context.Context, pattern string, limit int) ([]Artist, error) {
|
||||||
|
rows, err := s.db.QueryContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count, COALESCE(a.cover_art_id, '')
|
||||||
|
FROM artists a
|
||||||
|
LEFT JOIN albums al ON al.artist_id = a.id
|
||||||
|
WHERE a.name LIKE ?
|
||||||
|
GROUP BY a.id, a.name, a.cover_art_id
|
||||||
|
ORDER BY a.name ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
pattern,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search artists: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var artists []Artist
|
||||||
|
for rows.Next() {
|
||||||
|
var artist Artist
|
||||||
|
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan searched artist: %w", err)
|
||||||
|
}
|
||||||
|
artists = append(artists, artist)
|
||||||
|
}
|
||||||
|
return artists, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) ([]Album, error) {
|
||||||
|
rows, err := s.db.QueryContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.cover_art_id, '')
|
||||||
|
FROM albums al
|
||||||
|
JOIN artists a ON a.id = al.artist_id
|
||||||
|
LEFT JOIN tracks t ON t.album_id = al.id
|
||||||
|
WHERE al.title LIKE ? OR a.name LIKE ?
|
||||||
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
||||||
|
ORDER BY al.year DESC, al.title ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
pattern,
|
||||||
|
pattern,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search albums: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var albums []Album
|
||||||
|
for rows.Next() {
|
||||||
|
var album Album
|
||||||
|
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan searched album: %w", err)
|
||||||
|
}
|
||||||
|
albums = append(albums, album)
|
||||||
|
}
|
||||||
|
return albums, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) ([]Track, error) {
|
||||||
|
rows, err := s.db.QueryContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
||||||
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
||||||
|
FROM tracks t
|
||||||
|
JOIN artists a ON a.id = t.artist_id
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
|
WHERE t.title LIKE ? OR a.name LIKE ? OR al.title LIKE ?
|
||||||
|
ORDER BY a.name ASC, al.title ASC, t.track_number ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
pattern,
|
||||||
|
pattern,
|
||||||
|
pattern,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search tracks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tracks []Track
|
||||||
|
for rows.Next() {
|
||||||
|
var track Track
|
||||||
|
if err := rows.Scan(
|
||||||
|
&track.ID,
|
||||||
|
&track.AlbumID,
|
||||||
|
&track.ArtistID,
|
||||||
|
&track.Title,
|
||||||
|
&track.ArtistName,
|
||||||
|
&track.AlbumTitle,
|
||||||
|
&track.TrackNumber,
|
||||||
|
&track.DurationSecs,
|
||||||
|
&track.FilePath,
|
||||||
|
&track.ContentType,
|
||||||
|
&track.CoverArtID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan searched track: %w", err)
|
||||||
|
}
|
||||||
|
tracks = append(tracks, track)
|
||||||
|
}
|
||||||
|
return tracks, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type Response struct {
|
|||||||
Server string `json:"serverVersion"`
|
Server string `json:"serverVersion"`
|
||||||
OpenAPI bool `json:"openSubsonic"`
|
OpenAPI bool `json:"openSubsonic"`
|
||||||
Artists []ArtistRef `json:"artists,omitempty"`
|
Artists []ArtistRef `json:"artists,omitempty"`
|
||||||
|
Artist *ArtistFull `json:"artist,omitempty"`
|
||||||
|
Album *AlbumFull `json:"album,omitempty"`
|
||||||
|
Song *SongFull `json:"song,omitempty"`
|
||||||
RandomSong []SongRef `json:"randomSongs,omitempty"`
|
RandomSong []SongRef `json:"randomSongs,omitempty"`
|
||||||
ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
|
ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
|
||||||
Error *ErrorRef `json:"error,omitempty"`
|
Error *ErrorRef `json:"error,omitempty"`
|
||||||
@@ -35,6 +38,45 @@ type SongRef struct {
|
|||||||
Artist string `json:"artist"`
|
Artist string `json:"artist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArtistFull struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CoverArt string `json:"coverArt,omitempty"`
|
||||||
|
AlbumCount int `json:"albumCount,omitempty"`
|
||||||
|
Albums []AlbumRef `json:"album,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumRef struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
ArtistID string `json:"artistId"`
|
||||||
|
Year int `json:"year,omitempty"`
|
||||||
|
CoverArt string `json:"coverArt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumFull struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
ArtistID string `json:"artistId"`
|
||||||
|
Year int `json:"year,omitempty"`
|
||||||
|
CoverArt string `json:"coverArt,omitempty"`
|
||||||
|
Song []SongRef `json:"song,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SongFull struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
AlbumID string `json:"albumId"`
|
||||||
|
ArtistID string `json:"artistId"`
|
||||||
|
Track int `json:"track,omitempty"`
|
||||||
|
Duration int `json:"duration,omitempty"`
|
||||||
|
CoverArt string `json:"coverArt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ScanStatus struct {
|
type ScanStatus struct {
|
||||||
Scanning bool `json:"scanning"`
|
Scanning bool `json:"scanning"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
@@ -85,6 +127,66 @@ func RandomSongsResponse(tracks []library.Track) Envelope {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ArtistResponse(artist library.ArtistDetail) Envelope {
|
||||||
|
response := PingResponse()
|
||||||
|
item := &ArtistFull{
|
||||||
|
ID: artist.ID,
|
||||||
|
Name: artist.Name,
|
||||||
|
CoverArt: artist.CoverArtID,
|
||||||
|
AlbumCount: artist.AlbumCount,
|
||||||
|
}
|
||||||
|
for _, album := range artist.Albums {
|
||||||
|
item.Albums = append(item.Albums, AlbumRef{
|
||||||
|
ID: album.ID,
|
||||||
|
Name: album.Title,
|
||||||
|
Artist: album.ArtistName,
|
||||||
|
ArtistID: album.ArtistID,
|
||||||
|
Year: album.Year,
|
||||||
|
CoverArt: album.CoverArtID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
response.SubsonicResponse.Artist = item
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func AlbumResponse(album library.AlbumDetail) Envelope {
|
||||||
|
response := PingResponse()
|
||||||
|
item := &AlbumFull{
|
||||||
|
ID: album.ID,
|
||||||
|
Name: album.Title,
|
||||||
|
Artist: album.ArtistName,
|
||||||
|
ArtistID: album.ArtistID,
|
||||||
|
Year: album.Year,
|
||||||
|
CoverArt: album.CoverArtID,
|
||||||
|
}
|
||||||
|
for _, track := range album.Tracks {
|
||||||
|
item.Song = append(item.Song, SongRef{
|
||||||
|
ID: track.ID,
|
||||||
|
Title: track.Title,
|
||||||
|
Album: track.AlbumTitle,
|
||||||
|
Artist: track.ArtistName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
response.SubsonicResponse.Album = item
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func SongResponse(track library.Track) Envelope {
|
||||||
|
response := PingResponse()
|
||||||
|
response.SubsonicResponse.Song = &SongFull{
|
||||||
|
ID: track.ID,
|
||||||
|
Title: track.Title,
|
||||||
|
Album: track.AlbumTitle,
|
||||||
|
Artist: track.ArtistName,
|
||||||
|
AlbumID: track.AlbumID,
|
||||||
|
ArtistID: track.ArtistID,
|
||||||
|
Track: track.TrackNumber,
|
||||||
|
Duration: track.DurationSecs,
|
||||||
|
CoverArt: track.CoverArtID,
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
func ScanStatusResponse(status scanner.Status) Envelope {
|
func ScanStatusResponse(status scanner.Status) Envelope {
|
||||||
response := PingResponse()
|
response := PingResponse()
|
||||||
response.SubsonicResponse.ScanStatus = &ScanStatus{
|
response.SubsonicResponse.ScanStatus = &ScanStatus{
|
||||||
|
|||||||
Reference in New Issue
Block a user