feat: add subsonic library discovery endpoints

This commit is contained in:
2026-04-03 21:07:58 +03:00
parent 480bdc2476
commit ad9543bf7a
3 changed files with 189 additions and 10 deletions

View File

@@ -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 == "" {

View File

@@ -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)

View File

@@ -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"