diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index f2c14ae..f23886f 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -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 == "" { diff --git a/internal/library/service.go b/internal/library/service.go index c0abde6..5e7204b 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -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) diff --git a/internal/subsonic/service.go b/internal/subsonic/service.go index 6e4bb93..6d08de2 100644 --- a/internal/subsonic/service.go +++ b/internal/subsonic/service.go @@ -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"