mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
* feat(subsonic): append album version to album names in Subsonic API responses Add AppendAlbumVersion config option (default: true) that appends the album version tag to album names in Subsonic API responses, similar to how AppendSubtitle works for track titles. This affects album names in childFromAlbum and buildAlbumID3 responses. Signed-off-by: Deluan <deluan@navidrome.org> * feat(subsonic): append album version to media file album names in Subsonic API Add FullAlbumName() to MediaFile that appends the album version tag, mirroring the Album.FullName() behavior. Use it in childFromMediaFile and fakePath to ensure media file responses also show the album version. Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): use len() check for album version tag to prevent panic on empty slice Use len(tags) > 0 instead of != nil to safely guard against empty slices when accessing the first element of the album version tag. Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): use FullName in buildAlbumDirectory and deduplicate FullName calls Apply album.FullName() in buildAlbumDirectory (getMusicDirectory) so album names are consistent across all Subsonic endpoints. Also compute al.FullName() once in childFromAlbum to avoid redundant calls. Signed-off-by: Deluan <deluan@navidrome.org> * fix: use len() check in MediaFile.FullTitle() to prevent panic on empty slice Apply the same safety improvement as FullAlbumName() and Album.FullName() for consistency. Signed-off-by: Deluan <deluan@navidrome.org> * test: add tests for Album.FullName, MediaFile.FullTitle, and MediaFile.FullAlbumName Cover all cases: config enabled/disabled, tag present, tag absent, and empty tag slice. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
476 lines
13 KiB
Go
476 lines
13 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/publicurl"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server/subsonic/filter"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) {
|
|
libraries := getUserAccessibleLibraries(r.Context())
|
|
|
|
folders := make([]responses.MusicFolder, len(libraries))
|
|
for i, f := range libraries {
|
|
folders[i].Id = int32(f.ID)
|
|
folders[i].Name = f.Name
|
|
}
|
|
response := newResponse()
|
|
response.MusicFolders = &responses.MusicFolders{Folders: folders}
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) {
|
|
ctx := r.Context()
|
|
|
|
lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving last scan start time", err)
|
|
return nil, 0, err
|
|
}
|
|
lastScan := time.Now()
|
|
if lastScanStr != "" {
|
|
lastScan, err = time.Parse(time.RFC3339, lastScanStr)
|
|
}
|
|
|
|
var indexes model.ArtistIndexes
|
|
if lastScan.After(ifModifiedSince) {
|
|
indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist)
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving Indexes", err)
|
|
return nil, 0, err
|
|
}
|
|
if len(indexes) == 0 {
|
|
log.Debug(ctx, "No artists found in library", "libId", libIds)
|
|
return nil, 0, newError(responses.ErrorDataNotFound, "Library not found or empty")
|
|
}
|
|
}
|
|
|
|
return indexes, lastScan.UnixMilli(), err
|
|
}
|
|
|
|
func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
|
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := &responses.Indexes{
|
|
IgnoredArticles: conf.Server.IgnoredArticles,
|
|
LastModified: modified,
|
|
}
|
|
|
|
res.Index = make([]responses.Index, len(indexes))
|
|
for i, idx := range indexes {
|
|
res.Index[i].Name = idx.ID
|
|
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtist)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) {
|
|
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res := &responses.Artists{
|
|
IgnoredArticles: conf.Server.IgnoredArticles,
|
|
LastModified: modified,
|
|
}
|
|
|
|
res.Index = make([]responses.IndexID3, len(indexes))
|
|
for i, idx := range indexes {
|
|
res.Index[i].Name = idx.ID
|
|
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtistID3)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
|
|
p := req.Params(r)
|
|
musicFolderIds, _ := selectedMusicFolderIds(r, false)
|
|
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
|
|
|
|
res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.Indexes = res
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) {
|
|
musicFolderIds, _ := selectedMusicFolderIds(r, false)
|
|
|
|
res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.Artist = res
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, error) {
|
|
p := req.Params(r)
|
|
id, _ := p.String("id")
|
|
ctx := r.Context()
|
|
|
|
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Error(r, "Requested ID not found ", "id", id)
|
|
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
|
}
|
|
if err != nil {
|
|
log.Error(err)
|
|
return nil, err
|
|
}
|
|
|
|
var dir *responses.Directory
|
|
|
|
switch v := entity.(type) {
|
|
case *model.Artist:
|
|
dir, err = api.buildArtistDirectory(ctx, v)
|
|
case *model.Album:
|
|
dir, err = api.buildAlbumDirectory(ctx, v)
|
|
default:
|
|
log.Error(r, "Requested ID of invalid type", "id", id, "entity", v)
|
|
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
|
}
|
|
|
|
if err != nil {
|
|
log.Error(err)
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.Directory = dir
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) {
|
|
p := req.Params(r)
|
|
id, _ := p.String("id")
|
|
ctx := r.Context()
|
|
|
|
artist, err := api.ds.Artist(ctx).Get(id)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Error(ctx, "Requested ArtistID not found ", "id", id)
|
|
return nil, newError(responses.ErrorDataNotFound, "Artist not found")
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving artist", "id", id, err)
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.ArtistWithAlbumsID3, err = api.buildArtist(r, artist)
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving albums by artist", "id", artist.ID, "name", artist.Name, err)
|
|
}
|
|
return response, err
|
|
}
|
|
|
|
func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) {
|
|
p := req.Params(r)
|
|
id, _ := p.String("id")
|
|
|
|
ctx := r.Context()
|
|
|
|
album, err := api.ds.Album(ctx).Get(id)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Error(ctx, "Requested AlbumID not found ", "id", id)
|
|
return nil, newError(responses.ErrorDataNotFound, "Album not found")
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving album", "id", id, err)
|
|
return nil, err
|
|
}
|
|
|
|
mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(id))
|
|
if err != nil {
|
|
log.Error(ctx, "Error retrieving tracks from album", "id", id, "name", album.Name, err)
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.AlbumWithSongsID3 = api.buildAlbum(ctx, album, mfs)
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) {
|
|
p := req.Params(r)
|
|
id, err := p.String("id")
|
|
ctx := r.Context()
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
album, err := api.provider.UpdateAlbumInfo(ctx, id)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.AlbumInfo = &responses.AlbumInfo{}
|
|
response.AlbumInfo.Notes = album.Description
|
|
response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300)
|
|
response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600)
|
|
response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200)
|
|
|
|
response.AlbumInfo.LastFmUrl = album.ExternalUrl
|
|
response.AlbumInfo.MusicBrainzID = album.MbzAlbumID
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) {
|
|
p := req.Params(r)
|
|
id, _ := p.String("id")
|
|
ctx := r.Context()
|
|
|
|
mf, err := api.ds.MediaFile(ctx).Get(id)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Error(r, "Requested MediaFileID not found ", "id", id)
|
|
return nil, newError(responses.ErrorDataNotFound, "Song not found")
|
|
}
|
|
if err != nil {
|
|
log.Error(r, "Error retrieving MediaFile", "id", id, err)
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
child := childFromMediaFile(ctx, *mf)
|
|
response.Song = &child
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) {
|
|
ctx := r.Context()
|
|
genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"})
|
|
if err != nil {
|
|
log.Error(r, err)
|
|
return nil, err
|
|
}
|
|
for i, g := range genres {
|
|
if g.Name == "" {
|
|
genres[i].Name = "<Empty>"
|
|
}
|
|
}
|
|
|
|
response := newResponse()
|
|
response.Genres = toGenres(genres)
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *model.Artists, error) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
id, err := p.String("id")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
count := p.IntOr("count", 20)
|
|
includeNotPresent := p.BoolOr("includeNotPresent", false)
|
|
|
|
artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
base := responses.ArtistInfoBase{}
|
|
base.Biography = artist.Biography
|
|
base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300)
|
|
base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600)
|
|
base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200)
|
|
base.LastFmUrl = artist.ExternalUrl
|
|
base.MusicBrainzID = artist.MbzArtistID
|
|
|
|
return &base, &artist.SimilarArtists, nil
|
|
}
|
|
|
|
func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) {
|
|
base, similarArtists, err := api.getArtistInfo(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.ArtistInfo = &responses.ArtistInfo{}
|
|
response.ArtistInfo.ArtistInfoBase = *base
|
|
|
|
for _, s := range *similarArtists {
|
|
similar := toArtist(r, s)
|
|
if s.ID == "" {
|
|
similar.Id = "-1"
|
|
}
|
|
response.ArtistInfo.SimilarArtist = append(response.ArtistInfo.SimilarArtist, similar)
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetArtistInfo2(r *http.Request) (*responses.Subsonic, error) {
|
|
base, similarArtists, err := api.getArtistInfo(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.ArtistInfo2 = &responses.ArtistInfo2{}
|
|
response.ArtistInfo2.ArtistInfoBase = *base
|
|
|
|
for _, s := range *similarArtists {
|
|
similar := toArtistID3(r, s)
|
|
if s.ID == "" {
|
|
similar.Id = "-1"
|
|
}
|
|
response.ArtistInfo2.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar)
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
id, err := p.String("id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
count := p.IntOr("count", 50)
|
|
|
|
songs, err := api.provider.SimilarSongs(ctx, id, count)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.SimilarSongs = &responses.SimilarSongs{
|
|
Song: slice.MapWithArg(songs, ctx, childFromMediaFile),
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error) {
|
|
res, err := api.GetSimilarSongs(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.SimilarSongs2 = &responses.SimilarSongs2{
|
|
Song: res.SimilarSongs.Song,
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
artist, err := p.String("artist")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
count := p.IntOr("count", 50)
|
|
|
|
songs, err := api.provider.TopSongs(ctx, artist, count)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.TopSongs = &responses.TopSongs{
|
|
Song: slice.MapWithArg(songs, ctx, childFromMediaFile),
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artist) (*responses.Directory, error) {
|
|
dir := &responses.Directory{}
|
|
dir.Id = artist.ID
|
|
dir.Name = artist.Name
|
|
dir.PlayCount = artist.PlayCount
|
|
if artist.PlayCount > 0 {
|
|
dir.Played = artist.PlayDate
|
|
}
|
|
dir.AlbumCount = getArtistAlbumCount(artist)
|
|
dir.UserRating = int32(artist.Rating)
|
|
if conf.Server.Subsonic.EnableAverageRating {
|
|
dir.AverageRating = artist.AverageRating
|
|
}
|
|
if artist.Starred {
|
|
dir.Starred = artist.StarredAt
|
|
}
|
|
|
|
albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dir.Child = slice.MapWithArg(albums, ctx, childFromAlbum)
|
|
return dir, nil
|
|
}
|
|
|
|
func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*responses.ArtistWithAlbumsID3, error) {
|
|
ctx := r.Context()
|
|
a := &responses.ArtistWithAlbumsID3{}
|
|
a.ArtistID3 = toArtistID3(r, *artist)
|
|
|
|
albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
a.Album = slice.MapWithArg(albums, ctx, buildAlbumID3)
|
|
return a, nil
|
|
}
|
|
|
|
func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) {
|
|
dir := &responses.Directory{}
|
|
dir.Id = album.ID
|
|
dir.Name = album.FullName()
|
|
dir.Parent = album.AlbumArtistID
|
|
dir.PlayCount = album.PlayCount
|
|
if album.PlayCount > 0 {
|
|
dir.Played = album.PlayDate
|
|
}
|
|
dir.UserRating = int32(album.Rating)
|
|
if conf.Server.Subsonic.EnableAverageRating {
|
|
dir.AverageRating = album.AverageRating
|
|
}
|
|
dir.SongCount = int32(album.SongCount)
|
|
dir.CoverArt = album.CoverArtID().String()
|
|
if album.Starred {
|
|
dir.Starred = album.StarredAt
|
|
}
|
|
|
|
mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(album.ID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dir.Child = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
|
return dir, nil
|
|
}
|
|
|
|
func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model.MediaFiles) *responses.AlbumWithSongsID3 {
|
|
dir := &responses.AlbumWithSongsID3{}
|
|
dir.AlbumID3 = buildAlbumID3(ctx, *album)
|
|
dir.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
|
return dir
|
|
}
|