fix: improve subsonic client compatibility

This commit is contained in:
2026-04-03 21:16:40 +03:00
parent a054192e45
commit 0b10dfe055
3 changed files with 166 additions and 23 deletions

View File

@@ -5,9 +5,11 @@ import (
"encoding/json"
"errors"
"log"
"math/rand"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -98,8 +100,10 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
restGet(authed, "getSong", application.subsonicSongByID)
restGet(authed, "getRandomSongs", application.subsonicRandomSongs)
restGet(authed, "getAlbumList2", application.subsonicAlbumList2)
restGet(authed, "getSongsByGenre", application.subsonicSongsByGenre)
restGet(authed, "getMusicFolders", application.subsonicMusicFolders)
restGet(authed, "getGenres", application.subsonicGenres)
restGet(authed, "getOpenSubsonicExtensions", application.subsonicOpenSubsonicExtensions)
restGet(authed, "getPodcasts", application.subsonicPodcasts)
restGet(authed, "getNewestPodcasts", application.subsonicNewestPodcasts)
restGet(authed, "getInternetRadioStations", application.subsonicInternetRadioStations)
@@ -469,14 +473,63 @@ func (a app) subsonicAlbumList2(w http.ResponseWriter, r *http.Request) {
size = parsed
}
}
albums, err := a.library.Albums(r.Context(), size)
offset := parsePositiveInt(r.URL.Query().Get("offset"))
albums, err := a.library.Albums(r.Context(), 5000)
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load albums"))
return
}
if genre := strings.TrimSpace(r.URL.Query().Get("genre")); genre != "" {
filtered := make([]library.Album, 0, len(albums))
for _, album := range albums {
if strings.EqualFold(strings.TrimSpace(album.Genre), genre) {
filtered = append(filtered, album)
}
}
albums = filtered
}
typeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type")))
switch typeName {
case "alphabeticalbyname":
sort.SliceStable(albums, func(i, j int) bool {
return strings.ToLower(albums[i].Title) < strings.ToLower(albums[j].Title)
})
case "random":
rand.Shuffle(len(albums), func(i, j int) {
albums[i], albums[j] = albums[j], albums[i]
})
default:
sort.SliceStable(albums, func(i, j int) bool {
return albums[i].Year > albums[j].Year
})
}
if offset > len(albums) {
albums = []library.Album{}
} else if offset > 0 {
albums = albums[offset:]
}
if size < len(albums) {
albums = albums[:size]
}
writeJSON(w, http.StatusOK, subsonic.AlbumList2Response(albums))
}
func (a app) subsonicSongsByGenre(w http.ResponseWriter, r *http.Request) {
genre := strings.TrimSpace(r.URL.Query().Get("genre"))
if genre == "" {
writeJSON(w, http.StatusBadRequest, subsonic.ErrorResponse(10, "missing genre"))
return
}
count := parsePositiveInt(r.URL.Query().Get("count"))
offset := parsePositiveInt(r.URL.Query().Get("offset"))
tracks, err := a.library.SongsByGenre(r.Context(), genre, count, offset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load songs by genre"))
return
}
writeJSON(w, http.StatusOK, subsonic.SongsByGenreResponse(tracks))
}
func (a app) subsonicMusicFolders(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.MusicFoldersResponse())
}
@@ -502,6 +555,10 @@ func (a app) subsonicInternetRadioStations(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, subsonic.InternetRadioStationsResponse())
}
func (a app) subsonicOpenSubsonicExtensions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.OpenSubsonicExtensionsResponse())
}
func (a app) subsonicSearch3(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("query"))
if query == "" {

View File

@@ -36,6 +36,7 @@ type Track struct {
Title string `json:"title"`
ArtistName string `json:"artistName"`
AlbumTitle string `json:"albumTitle"`
Genre string `json:"genre"`
TrackNumber int `json:"trackNumber"`
DurationSecs int `json:"durationSeconds"`
BitrateKbps int `json:"bitrateKbps"`
@@ -269,7 +270,7 @@ func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error)
func (s *Service) Tracks(ctx context.Context, 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),
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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
@@ -293,6 +294,7 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
&track.Title,
&track.ArtistName,
&track.AlbumTitle,
&track.Genre,
&track.TrackNumber,
&track.DurationSecs,
&track.BitrateKbps,
@@ -313,7 +315,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
err := s.db.QueryRowContext(
ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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
@@ -327,6 +329,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
&track.Title,
&track.ArtistName,
&track.AlbumTitle,
&track.Genre,
&track.TrackNumber,
&track.DurationSecs,
&track.BitrateKbps,
@@ -415,6 +418,36 @@ func (s *Service) Genres(ctx context.Context) ([]GenreSummary, error) {
return genres, rows.Err()
}
func (s *Service) SongsByGenre(ctx context.Context, genre string, count, offset int) ([]Track, error) {
if count <= 0 {
count = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.db.QueryContext(
ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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 LOWER(TRIM(COALESCE(al.genre, ''))) = LOWER(TRIM(?))
ORDER BY a.name ASC, al.year DESC, al.title ASC, t.disc_number ASC, t.track_number ASC
LIMIT ? OFFSET ?`,
genre,
count,
offset,
)
if err != nil {
return nil, fmt.Errorf("query songs by genre: %w", err)
}
defer rows.Close()
return scanTracks(rows)
}
func (s *Service) Star(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string) error {
return s.updateFavorites(ctx, userID, trackIDs, albumIDs, artistIDs, true)
}
@@ -463,7 +496,7 @@ func (s *Service) RecentTracks(ctx context.Context, userID string, limit int) ([
if userID != "" {
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),
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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
@@ -488,7 +521,7 @@ func (s *Service) RecentTracks(ctx context.Context, userID string, limit int) ([
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),
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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
@@ -617,7 +650,7 @@ func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Albu
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),
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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
@@ -697,8 +730,8 @@ func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) (
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, '')
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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
@@ -817,7 +850,7 @@ func (s *Service) starredAlbums(ctx context.Context, userID string) ([]Album, er
func (s *Service) starredTracks(ctx context.Context, userID 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),
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM favorites f
JOIN tracks t ON t.id = f.entity_id
@@ -846,6 +879,7 @@ func scanTracks(rows *sql.Rows) ([]Track, error) {
&track.Title,
&track.ArtistName,
&track.AlbumTitle,
&track.Genre,
&track.TrackNumber,
&track.DurationSecs,
&track.BitrateKbps,

View File

@@ -25,6 +25,7 @@ type Response struct {
Artist *ArtistFull `json:"artist,omitempty"`
Album *AlbumFull `json:"album,omitempty"`
AlbumList2 *AlbumList2 `json:"albumList2,omitempty"`
SongsByGenre *SongsByGenre `json:"songsByGenre,omitempty"`
Song *SongFull `json:"song,omitempty"`
RandomSong []SongRef `json:"randomSongs,omitempty"`
SearchResult3 *SearchResult3 `json:"searchResult3,omitempty"`
@@ -36,6 +37,7 @@ type Response struct {
Podcasts *Podcasts `json:"podcasts,omitempty"`
NewestPods *NewestPods `json:"newestPodcasts,omitempty"`
RadioStations *RadioStations `json:"internetRadioStations,omitempty"`
Extensions *Extensions `json:"openSubsonicExtensions,omitempty"`
ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
Error *ErrorRef `json:"error,omitempty"`
}
@@ -74,6 +76,7 @@ type ArtistFull struct {
type AlbumRef struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Title string `json:"title"`
Artist string `json:"artist"`
ArtistID string `json:"artistId"`
@@ -96,6 +99,10 @@ type AlbumList2 struct {
Album []AlbumRef `json:"album,omitempty"`
}
type SongsByGenre struct {
Song []SongRef `json:"song,omitempty"`
}
type SongFull struct {
ID string `json:"id"`
Title string `json:"title"`
@@ -188,6 +195,15 @@ type RadioStations struct {
InternetRadioStation []any `json:"internetRadioStation,omitempty"`
}
type Extensions struct {
Extension []Extension `json:"extension,omitempty"`
}
type Extension struct {
Name string `json:"name"`
Versions string `json:"versions"`
}
type ErrorRef struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -248,7 +264,7 @@ func RandomSongsResponse(tracks []library.Track) Envelope {
Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.CoverArtID,
CoverArt: track.AlbumID,
})
}
return response
@@ -265,12 +281,13 @@ func ArtistResponse(artist library.ArtistDetail) Envelope {
for _, album := range artist.Albums {
item.Albums = append(item.Albums, AlbumRef{
ID: album.ID,
Name: album.Title,
Title: album.Title,
Artist: album.ArtistName,
ArtistID: album.ArtistID,
Year: album.Year,
Genre: album.Genre,
CoverArt: album.CoverArtID,
CoverArt: album.ID,
})
}
response.SubsonicResponse.Artist = item
@@ -289,12 +306,13 @@ func Search3Response(results library.SearchResults) Envelope {
for _, album := range results.Albums {
payload.Album = append(payload.Album, AlbumRef{
ID: album.ID,
Name: album.Title,
Title: album.Title,
Artist: album.ArtistName,
ArtistID: album.ArtistID,
Year: album.Year,
Genre: album.Genre,
CoverArt: album.CoverArtID,
CoverArt: album.ID,
})
}
for _, track := range results.Tracks {
@@ -305,7 +323,7 @@ func Search3Response(results library.SearchResults) Envelope {
Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.CoverArtID,
CoverArt: track.AlbumID,
})
}
response.SubsonicResponse.SearchResult3 = payload
@@ -324,12 +342,13 @@ func Starred2Response(results library.StarredResults) Envelope {
for _, album := range results.Albums {
payload.Album = append(payload.Album, AlbumRef{
ID: album.ID,
Name: album.Title,
Title: album.Title,
Artist: album.ArtistName,
ArtistID: album.ArtistID,
Year: album.Year,
Genre: album.Genre,
CoverArt: album.CoverArtID,
CoverArt: album.ID,
})
}
for _, track := range results.Tracks {
@@ -340,7 +359,7 @@ func Starred2Response(results library.StarredResults) Envelope {
Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.CoverArtID,
CoverArt: track.AlbumID,
})
}
response.SubsonicResponse.Starred2 = payload
@@ -387,7 +406,7 @@ func PlaylistResponse(owner string, detail playlist.Detail) Envelope {
Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.CoverArtID,
CoverArt: track.AlbumID,
})
}
response.SubsonicResponse.Playlist = item
@@ -402,14 +421,17 @@ func AlbumResponse(album library.AlbumDetail) Envelope {
Artist: album.ArtistName,
ArtistID: album.ArtistID,
Year: album.Year,
CoverArt: album.CoverArtID,
CoverArt: album.ID,
}
for _, track := range album.Tracks {
item.Song = append(item.Song, SongRef{
ID: track.ID,
Title: track.Title,
Album: track.AlbumTitle,
Artist: track.ArtistName,
ID: track.ID,
Title: track.Title,
Album: track.AlbumTitle,
Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.AlbumID,
})
}
response.SubsonicResponse.Album = item
@@ -422,18 +444,37 @@ func AlbumList2Response(albums []library.Album) Envelope {
for _, album := range albums {
payload.Album = append(payload.Album, AlbumRef{
ID: album.ID,
Name: album.Title,
Title: album.Title,
Artist: album.ArtistName,
ArtistID: album.ArtistID,
Year: album.Year,
Genre: album.Genre,
CoverArt: album.CoverArtID,
CoverArt: album.ID,
})
}
response.SubsonicResponse.AlbumList2 = payload
return response
}
func SongsByGenreResponse(tracks []library.Track) Envelope {
response := PingResponse()
payload := &SongsByGenre{}
for _, track := range tracks {
payload.Song = append(payload.Song, SongRef{
ID: track.ID,
Title: track.Title,
Album: track.AlbumTitle,
Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.AlbumID,
})
}
response.SubsonicResponse.SongsByGenre = payload
return response
}
func SongResponse(track library.Track) Envelope {
response := PingResponse()
response.SubsonicResponse.Song = &SongFull{
@@ -445,7 +486,7 @@ func SongResponse(track library.Track) Envelope {
ArtistID: track.ArtistID,
Track: track.TrackNumber,
Duration: track.DurationSecs,
CoverArt: track.CoverArtID,
CoverArt: track.AlbumID,
}
return response
}
@@ -503,6 +544,17 @@ func InternetRadioStationsResponse() Envelope {
return response
}
func OpenSubsonicExtensionsResponse() Envelope {
response := PingResponse()
response.SubsonicResponse.Extensions = &Extensions{
Extension: []Extension{
{Name: "formPost", Versions: "1"},
{Name: "apiKeyAuthentication", Versions: "1"},
},
}
return response
}
func ErrorResponse(code int, message string) Envelope {
response := PingResponse()
response.SubsonicResponse.Status = "failed"