feat: add subsonic library discovery endpoints
This commit is contained in:
@@ -97,6 +97,12 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
restGet(authed, "getAlbum", application.subsonicAlbumByID)
|
||||
restGet(authed, "getSong", application.subsonicSongByID)
|
||||
restGet(authed, "getRandomSongs", application.subsonicRandomSongs)
|
||||
restGet(authed, "getAlbumList2", application.subsonicAlbumList2)
|
||||
restGet(authed, "getMusicFolders", application.subsonicMusicFolders)
|
||||
restGet(authed, "getGenres", application.subsonicGenres)
|
||||
restGet(authed, "getPodcasts", application.subsonicPodcasts)
|
||||
restGet(authed, "getNewestPodcasts", application.subsonicNewestPodcasts)
|
||||
restGet(authed, "getInternetRadioStations", application.subsonicInternetRadioStations)
|
||||
restGet(authed, "search3", application.subsonicSearch3)
|
||||
restGet(authed, "getStarred2", application.subsonicStarred2)
|
||||
restGet(authed, "star", application.subsonicStar)
|
||||
@@ -456,6 +462,46 @@ func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks))
|
||||
}
|
||||
|
||||
func (a app) subsonicAlbumList2(w http.ResponseWriter, r *http.Request) {
|
||||
size := 60
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("size")); raw != "" {
|
||||
if parsed := parsePositiveInt(raw); parsed > 0 {
|
||||
size = parsed
|
||||
}
|
||||
}
|
||||
albums, err := a.library.Albums(r.Context(), size)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load albums"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, subsonic.AlbumList2Response(albums))
|
||||
}
|
||||
|
||||
func (a app) subsonicMusicFolders(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, subsonic.MusicFoldersResponse())
|
||||
}
|
||||
|
||||
func (a app) subsonicGenres(w http.ResponseWriter, r *http.Request) {
|
||||
genres, err := a.library.Genres(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load genres"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, subsonic.GenresResponse(genres))
|
||||
}
|
||||
|
||||
func (a app) subsonicPodcasts(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, subsonic.PodcastsResponse())
|
||||
}
|
||||
|
||||
func (a app) subsonicNewestPodcasts(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, subsonic.NewestPodcastsResponse())
|
||||
}
|
||||
|
||||
func (a app) subsonicInternetRadioStations(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, subsonic.InternetRadioStationsResponse())
|
||||
}
|
||||
|
||||
func (a app) subsonicSearch3(w http.ResponseWriter, r *http.Request) {
|
||||
query := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if query == "" {
|
||||
|
||||
@@ -25,6 +25,7 @@ type Album struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
TrackCount int `json:"trackCount"`
|
||||
Genre string `json:"genre"`
|
||||
CoverArtID string `json:"coverArtId"`
|
||||
}
|
||||
|
||||
@@ -73,6 +74,12 @@ type StarredResults struct {
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
|
||||
type GenreSummary struct {
|
||||
Value string `json:"value"`
|
||||
AlbumCount int `json:"albumCount"`
|
||||
SongCount int `json:"songCount"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
}
|
||||
@@ -165,6 +172,7 @@ func (s *Service) RecentAlbums(ctx context.Context, 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) AS track_count
|
||||
, COALESCE(al.genre, '')
|
||||
, COALESCE(al.cover_art_id, '')
|
||||
FROM albums al
|
||||
JOIN artists a ON a.id = al.artist_id
|
||||
@@ -182,7 +190,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
|
||||
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 {
|
||||
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan album: %w", err)
|
||||
}
|
||||
albums = append(albums, album)
|
||||
@@ -194,7 +202,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
|
||||
func (s *Service) Albums(ctx context.Context, 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) AS track_count, COALESCE(al.cover_art_id, '')
|
||||
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.genre, ''), 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
|
||||
@@ -211,7 +219,7 @@ func (s *Service) Albums(ctx context.Context, limit int) ([]Album, error) {
|
||||
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 {
|
||||
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan all albums: %w", err)
|
||||
}
|
||||
albums = append(albums, album)
|
||||
@@ -225,7 +233,7 @@ func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error)
|
||||
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, '')
|
||||
COUNT(t.id) AS track_count, COALESCE(al.genre, ''), 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
|
||||
@@ -239,6 +247,7 @@ func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error)
|
||||
&album.Title,
|
||||
&album.Year,
|
||||
&album.TrackCount,
|
||||
&album.Genre,
|
||||
&album.CoverArtID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -380,6 +389,32 @@ func (s *Service) Starred(ctx context.Context, userID string) (StarredResults, e
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Genres(ctx context.Context) ([]GenreSummary, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT al.genre, COUNT(DISTINCT al.id) AS album_count, COUNT(t.id) AS song_count
|
||||
FROM albums al
|
||||
LEFT JOIN tracks t ON t.album_id = al.id
|
||||
WHERE TRIM(COALESCE(al.genre, '')) <> ''
|
||||
GROUP BY al.genre
|
||||
ORDER BY song_count DESC, album_count DESC, al.genre ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query genres: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var genres []GenreSummary
|
||||
for rows.Next() {
|
||||
var genre GenreSummary
|
||||
if err := rows.Scan(&genre.Value, &genre.AlbumCount, &genre.SongCount); err != nil {
|
||||
return nil, fmt.Errorf("scan genre: %w", err)
|
||||
}
|
||||
genres = append(genres, genre)
|
||||
}
|
||||
return genres, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Star(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string) error {
|
||||
return s.updateFavorites(ctx, userID, trackIDs, albumIDs, artistIDs, true)
|
||||
}
|
||||
@@ -553,7 +588,7 @@ func (s *Service) PopulateTrackStats(ctx context.Context, userID string, tracks
|
||||
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, '')
|
||||
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.genre, ''), 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
|
||||
@@ -570,7 +605,7 @@ func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Albu
|
||||
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 {
|
||||
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan album by artist: %w", err)
|
||||
}
|
||||
albums = append(albums, album)
|
||||
@@ -631,7 +666,7 @@ func (s *Service) searchArtists(ctx context.Context, pattern string, limit int)
|
||||
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, '')
|
||||
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.genre, ''), 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
|
||||
@@ -651,7 +686,7 @@ func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) (
|
||||
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 {
|
||||
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan searched album: %w", err)
|
||||
}
|
||||
albums = append(albums, album)
|
||||
@@ -753,7 +788,7 @@ func (s *Service) starredArtists(ctx context.Context, userID string) ([]Artist,
|
||||
func (s *Service) starredAlbums(ctx context.Context, userID 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), COALESCE(al.cover_art_id, '')
|
||||
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.genre, ''), COALESCE(al.cover_art_id, '')
|
||||
FROM favorites f
|
||||
JOIN albums al ON al.id = f.entity_id
|
||||
JOIN artists a ON a.id = al.artist_id
|
||||
@@ -771,7 +806,7 @@ func (s *Service) starredAlbums(ctx context.Context, userID string) ([]Album, er
|
||||
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 {
|
||||
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan starred album: %w", err)
|
||||
}
|
||||
albums = append(albums, album)
|
||||
|
||||
@@ -21,12 +21,18 @@ type Response struct {
|
||||
Artists []ArtistRef `json:"artists,omitempty"`
|
||||
Artist *ArtistFull `json:"artist,omitempty"`
|
||||
Album *AlbumFull `json:"album,omitempty"`
|
||||
AlbumList2 *AlbumList2 `json:"albumList2,omitempty"`
|
||||
Song *SongFull `json:"song,omitempty"`
|
||||
RandomSong []SongRef `json:"randomSongs,omitempty"`
|
||||
SearchResult3 *SearchResult3 `json:"searchResult3,omitempty"`
|
||||
Starred2 *Starred2 `json:"starred2,omitempty"`
|
||||
Playlists *Playlists `json:"playlists,omitempty"`
|
||||
Playlist *Playlist `json:"playlist,omitempty"`
|
||||
MusicFolders *MusicFolders `json:"musicFolders,omitempty"`
|
||||
Genres *Genres `json:"genres,omitempty"`
|
||||
Podcasts *Podcasts `json:"podcasts,omitempty"`
|
||||
NewestPods *NewestPods `json:"newestPodcasts,omitempty"`
|
||||
RadioStations *RadioStations `json:"internetRadioStations,omitempty"`
|
||||
ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
|
||||
Error *ErrorRef `json:"error,omitempty"`
|
||||
}
|
||||
@@ -73,6 +79,10 @@ type AlbumFull struct {
|
||||
Song []SongRef `json:"song,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumList2 struct {
|
||||
Album []AlbumRef `json:"album,omitempty"`
|
||||
}
|
||||
|
||||
type SongFull struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -134,6 +144,37 @@ type PlaylistSummary struct {
|
||||
Changed string `json:"changed,omitempty"`
|
||||
}
|
||||
|
||||
type MusicFolders struct {
|
||||
MusicFolder []MusicFolder `json:"musicFolder,omitempty"`
|
||||
}
|
||||
|
||||
type MusicFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Genres struct {
|
||||
Genre []Genre `json:"genre,omitempty"`
|
||||
}
|
||||
|
||||
type Genre struct {
|
||||
Value string `json:"value"`
|
||||
AlbumCount int `json:"albumCount,omitempty"`
|
||||
SongCount int `json:"songCount,omitempty"`
|
||||
}
|
||||
|
||||
type Podcasts struct {
|
||||
Channel []any `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
type NewestPods struct {
|
||||
Episode []any `json:"episode,omitempty"`
|
||||
}
|
||||
|
||||
type RadioStations struct {
|
||||
InternetRadioStation []any `json:"internetRadioStation,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorRef struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
@@ -337,6 +378,23 @@ func AlbumResponse(album library.AlbumDetail) Envelope {
|
||||
return response
|
||||
}
|
||||
|
||||
func AlbumList2Response(albums []library.Album) Envelope {
|
||||
response := PingResponse()
|
||||
payload := &AlbumList2{}
|
||||
for _, album := range albums {
|
||||
payload.Album = append(payload.Album, AlbumRef{
|
||||
ID: album.ID,
|
||||
Name: album.Title,
|
||||
Artist: album.ArtistName,
|
||||
ArtistID: album.ArtistID,
|
||||
Year: album.Year,
|
||||
CoverArt: album.CoverArtID,
|
||||
})
|
||||
}
|
||||
response.SubsonicResponse.AlbumList2 = payload
|
||||
return response
|
||||
}
|
||||
|
||||
func SongResponse(track library.Track) Envelope {
|
||||
response := PingResponse()
|
||||
response.SubsonicResponse.Song = &SongFull{
|
||||
@@ -366,6 +424,46 @@ func ScanStatusResponse(status scanner.Status) Envelope {
|
||||
return response
|
||||
}
|
||||
|
||||
func MusicFoldersResponse() Envelope {
|
||||
response := PingResponse()
|
||||
response.SubsonicResponse.MusicFolders = &MusicFolders{
|
||||
MusicFolder: []MusicFolder{{ID: "1", Name: "Music"}},
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func GenresResponse(genres []library.GenreSummary) Envelope {
|
||||
response := PingResponse()
|
||||
payload := &Genres{}
|
||||
for _, genre := range genres {
|
||||
payload.Genre = append(payload.Genre, Genre{
|
||||
Value: genre.Value,
|
||||
AlbumCount: genre.AlbumCount,
|
||||
SongCount: genre.SongCount,
|
||||
})
|
||||
}
|
||||
response.SubsonicResponse.Genres = payload
|
||||
return response
|
||||
}
|
||||
|
||||
func PodcastsResponse() Envelope {
|
||||
response := PingResponse()
|
||||
response.SubsonicResponse.Podcasts = &Podcasts{}
|
||||
return response
|
||||
}
|
||||
|
||||
func NewestPodcastsResponse() Envelope {
|
||||
response := PingResponse()
|
||||
response.SubsonicResponse.NewestPods = &NewestPods{}
|
||||
return response
|
||||
}
|
||||
|
||||
func InternetRadioStationsResponse() Envelope {
|
||||
response := PingResponse()
|
||||
response.SubsonicResponse.RadioStations = &RadioStations{}
|
||||
return response
|
||||
}
|
||||
|
||||
func ErrorResponse(code int, message string) Envelope {
|
||||
response := PingResponse()
|
||||
response.SubsonicResponse.Status = "failed"
|
||||
|
||||
Reference in New Issue
Block a user