diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index f23886f..1c822a9 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -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 == "" { diff --git a/internal/library/service.go b/internal/library/service.go index 5e7204b..99b0809 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -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, diff --git a/internal/subsonic/service.go b/internal/subsonic/service.go index e478507..538ced8 100644 --- a/internal/subsonic/service.go +++ b/internal/subsonic/service.go @@ -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"