Files
navidrome/server/subsonic/responses/responses.go
T
Deluan Quintão ae1e0ddb11 feat(subsonic): implement OpenSubsonic Transcoding extension (#4990)
* feat(subsonic): implement transcode decision logic and codec handling for media files

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): update codec limitation structure and decision logic for improved clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): simplify container alias handling in matchesContainer function

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): enhance logging for transcode decision process and client info conversion

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): enhance transcoding config lookup logic for audio codecs

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): enhance transcoding options with sample rate support and improve command handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): add bit depth support for audio transcoding and enhance related logic

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): enhance AAC command handling and support for audio channels in streaming

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): update default command handling and add codec support for transcoding

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: implement noopDecider for transcoding decision handling in tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: address review findings for OpenSubsonic transcoding PR

Fix multiple issues identified during code review of the transcoding
extension: add missing return after error in shared stream handler
preventing nil pointer panic, replace dead r.Body nil check with
MaxBytesReader size limit, distinguish not-found from other DB errors,
fix bpsToKbps integer truncation with rounding, add "pcm" to
isLosslessFormat for consistency with model.IsLossless(), add
sampleRate/bitDepth/channels to streaming log, fix outdated test
comment, and add tests for conversion functions and GetTranscodeStream
parameter passing.

* feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: small issues

Updated mock AAC transcoding command to use the new default (ipod with
fragmented MP4) matching the migration, ensuring tests exercise the same
buildDynamicArgs code path as production. Improved archiver test mock to
match on the whole StreamRequest struct instead of decomposing fields,
making it resilient to future field additions. Added named constants for
JWT claim keys in the transcode token and wrapped ParseTranscodeParams
errors with ErrTokenInvalid for consistency. Documented the IsLossless
BitDepth fallback heuristic as temporary until Codec column is populated.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcoding): adapt transcode claims to struct-based auth.Claims

Updated transcode token handling to use the struct-based auth.Claims
introduced on master, replacing the previous map[string]any approach.
Extended auth.Claims with transcoding-specific fields (MediaID, DirectPlay,
UpdatedAt, Channels, SampleRate, BitDepth) and added float64 fallback in
ClaimsFromToken for numeric claims that lose their Go type during JWT
string serialization. Also added the missing lyrics parameter to all
subsonic.New() calls in test files.

* feat(model): add ProbeData field and UpdateProbeData repository method

Add probe_data TEXT column to media_file for caching ffprobe results.
Add UpdateProbeData to MediaFileRepository interface and implementations.
Use hash:"ignore" tag so probe data doesn't affect MediaFile fingerprints.

* feat(ffmpeg): add ProbeAudioStream for authoritative audio metadata

Add ProbeAudioStream to FFmpeg interface, using ffprobe to extract
codec, profile, bitrate, sample rate, bit depth, and channels.
Parse bits_per_raw_sample as fallback for FLAC/ALAC bit depth.
Normalize "unknown" profile to empty string.
All parseProbeOutput tests use real ffprobe JSON from actual files.

* feat(transcoding): integrate ffprobe into transcode decisions

Add ensureProbed to probe media files on first transcode decision,
caching results in probe_data. Build SourceStream from probe data
with fallback to tag-based metadata.

Refactor decision logic to pass StreamDetails instead of MediaFile,
enabling codec profile limitations (e.g., audioProfile) to use
probe data. Add normalizeProbeCodec to map ffprobe codec names
(dsd_lsbf_planar, pcm_s16le) to internal names (dsd, pcm).

NewDecider now accepts ffmpeg.FFmpeg; wire_gen.go regenerated.

* feat(transcoding): add DevEnableMediaFileProbe config flag

Add DevEnableMediaFileProbe (default true) to allow disabling ffprobe-
based media file probing as a safety fallback. When disabled, the
decider uses tag-based metadata from the scanner instead.

* test(transcode): add ensureProbed unit tests

Test probing when ProbeData is empty, skipping when already set,
error propagation from ffprobe, and DevEnableMediaFileProbe flag.

* refactor(ffmpeg): use command constant and select_streams for ProbeAudioStream

Move ffprobe arguments to a probeAudioStreamCmd constant, following the
same pattern as extractImageCmd and probeCmd. Add -select_streams a:0 to
only probe the first audio stream, avoiding unnecessary parsing of video
and artwork streams. Derive the ffprobe binary path safely using
filepath.Dir/Base instead of replacing within the full path string.

* refactor(transcode): decouple transcode token claims from auth.Claims

Remove six transcode-specific fields (MediaID, DirectPlay, UpdatedAt,
Channels, SampleRate, BitDepth) from auth.Claims, which is shared with
session and share tokens. Transcode tokens are signed parameter-passing
tokens, not authentication tokens, so coupling them to auth created
misleading dependencies.

The transcode package now owns its own JWT claim serialization via
Decision.toClaimsMap() and paramsFromToken(), using generic
auth.EncodeToken/DecodeAndVerifyToken wrappers that keep TokenAuth
encapsulated. Wire format (JWT claim keys) is unchanged, so in-flight
tokens remain compatible.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcode): simplify code after review

Extract getIntClaim helper to eliminate repeated int/int64/float64 JWT
claim extraction pattern in paramsFromToken and ClaimsFromToken. Rewrite
checkIntLimitation as a one-liner delegating to applyIntLimitation.
Return probe result from ensureProbed to avoid redundant JSON round-trip.
Extract toResponseStreamDetails helper and mediaTypeSong constant in
the API layer, and use transcode.ProtocolHTTP constant instead of
hardcoded string.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ffmpeg): enhance bit_rate parsing logic for audio streams

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcode): improve code review findings across transcode implementation

- Fix parseProbeData to return nil on JSON unmarshal failure instead of
  a zero-valued struct, preventing silent degradation of source stream details
- Use probe-resolved codec for lossless detection in buildSourceStream
  instead of the potentially stale scanner data
- Remove MediaFile.IsLossless() (dead code) and consolidate lossless
  detection in isLosslessFormat(), using codec name only — bit depth is
  not reliable since lossy codecs like ADPCM report non-zero values
- Add "wavpack" to lossless codec list (ffprobe codec_name for WavPack)
- Guard bpsToKbps against negative input values
- Fix misleading comment in buildTemplateArgs about conditional injection
- Avoid leaking internal error details in Subsonic API responses
- Add missing test for ErrNotFound branch in GetTranscodeDecision
- Add TODO for hardcoded protocol in toResponseStreamDetails

* refactor(transcode): streamline transcoding command lookup and format resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(transcode): implement server-side transcoding override for player formats

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcode): honor bit depth and channel constraints in transcoding selection

selectTranscodingOptions only checked sample rate when deciding whether
same-format transcoding was needed, ignoring requested bit depth and
channel reductions. This caused the streamer to return raw audio when
the transcode decision requested downmix or bit-depth conversion.

* refactor(transcode): unify streaming decision engine via MakeDecision

Move transcoding decision-making out of mediaStreamer and into the
subsonic Stream/Download handlers, using transcode.Decider.MakeDecision
as the single decision engine. This eliminates selectTranscodingOptions
and the mismatch between decision and streaming code paths (decision
used LookupTranscodeCommand with built-in fallbacks, while streaming
used FindByFormat which only checked the DB).

- Add DecisionOptions with SkipProbe to MakeDecision so the legacy
  streaming path never calls ffprobe
- Add buildLegacyClientInfo to translate legacy stream params (format,
  maxBitRate, DefaultDownsamplingFormat) into a synthetic ClientInfo
- Add resolveStreamRequest on the subsonic Router to resolve legacy
  params into a fully specified StreamRequest via MakeDecision
- Simplify DoStream to a dumb executor that receives pre-resolved params
- Remove selectTranscodingOptions entirely

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcode): move MediaStreamer into core/transcode and unify StreamRequest

Moved MediaStreamer, Stream, TranscodingCache and related types from
core/media_streamer.go into core/transcode/, eliminating the duplicate
StreamRequest type. The transcode.StreamRequest now carries all fields
(ID, Format, BitRate, SampleRate, BitDepth, Channels, Offset) and
ResolveStream returns a fully-populated value, removing manual field
copying at every call site. Also moved buildLegacyClientInfo into the
transcode package alongside ResolveStream, and unexported
ParseTranscodeParams since it was only used internally by
ValidateTranscodeParams.

* refactor(transcode): rename Decider methods and unexport Params type

Rename ResolveStream → ResolveRequest and ValidateTranscodeParams →
ResolveRequestFromToken for clarity and consistency. The new
ResolveRequestFromToken returns a StreamRequest directly (instead of
the intermediate Params type), eliminating manual Params→StreamRequest
conversion in callers. Unexport Params to params since it is now only
used internally for JWT token parsing.

* test(transcode): remove redundant tests and use constants

Remove tests that duplicate coverage from integration-level tests
(toClaimsMap, paramsFromToken round-trips, applyServerOverride direct
call, duplicate 410 handler test). Replace raw "http" strings with
ProtocolHTTP constant. Consolidate lossy -sample_fmt tests into
DescribeTable.

* refactor(transcode): split oversized files into focused modules

Split transcode.go and transcode_test.go into focused files by concern:
- decider.go: decision engine (MakeDecision, direct play/transcode evaluation, probe)
- token.go: JWT token encode/decode (params, toClaimsMap, paramsFromToken, CreateTranscodeParams, ResolveRequestFromToken)
- legacy_client.go: legacy Subsonic bridge (buildLegacyClientInfo, ResolveRequest)
- codec_test.go: isLosslessFormat and normalizeProbeCodec tests
- token_test.go: token round-trip and ResolveRequestFromToken tests

Moved the Decider interface from types.go to decider.go to keep it near
its implementation, and cleaned up types.go to contain only pure type
definitions and constants. No public API changes.

* refactor(transcode): reorder parameters in applyServerOverride function

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): add NewTestStream function and implement spyStreamer for testing

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-08 23:57:49 -04:00

644 lines
35 KiB
Go

package responses
import (
"encoding/json"
"encoding/xml"
"time"
)
type Subsonic struct {
XMLName xml.Name `xml:"http://subsonic.org/restapi subsonic-response" json:"-"`
Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"`
Type string `xml:"type,attr" json:"type"`
ServerVersion string `xml:"serverVersion,attr" json:"serverVersion"`
OpenSubsonic bool `xml:"openSubsonic,attr,omitempty" json:"openSubsonic,omitempty"`
Error *Error `xml:"error,omitempty" json:"error,omitempty"`
License *License `xml:"license,omitempty" json:"license,omitempty"`
MusicFolders *MusicFolders `xml:"musicFolders,omitempty" json:"musicFolders,omitempty"`
Indexes *Indexes `xml:"indexes,omitempty" json:"indexes,omitempty"`
Directory *Directory `xml:"directory,omitempty" json:"directory,omitempty"`
User *User `xml:"user,omitempty" json:"user,omitempty"`
Users *Users `xml:"users,omitempty" json:"users,omitempty"`
AlbumList *AlbumList `xml:"albumList,omitempty" json:"albumList,omitempty"`
AlbumList2 *AlbumList2 `xml:"albumList2,omitempty" json:"albumList2,omitempty"`
Playlists *Playlists `xml:"playlists,omitempty" json:"playlists,omitempty"`
Playlist *PlaylistWithSongs `xml:"playlist,omitempty" json:"playlist,omitempty"`
SearchResult2 *SearchResult2 `xml:"searchResult2,omitempty" json:"searchResult2,omitempty"`
SearchResult3 *SearchResult3 `xml:"searchResult3,omitempty" json:"searchResult3,omitempty"`
Starred *Starred `xml:"starred,omitempty" json:"starred,omitempty"`
Starred2 *Starred2 `xml:"starred2,omitempty" json:"starred2,omitempty"`
NowPlaying *NowPlaying `xml:"nowPlaying,omitempty" json:"nowPlaying,omitempty"`
Song *Child `xml:"song,omitempty" json:"song,omitempty"`
RandomSongs *Songs `xml:"randomSongs,omitempty" json:"randomSongs,omitempty"`
SongsByGenre *Songs `xml:"songsByGenre,omitempty" json:"songsByGenre,omitempty"`
Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"`
// ID3
Artist *Artists `xml:"artists,omitempty" json:"artists,omitempty"`
ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"`
AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"`
AlbumInfo *AlbumInfo `xml:"albumInfo,omitempty" json:"albumInfo,omitempty"`
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
SimilarSongs *SimilarSongs `xml:"similarSongs,omitempty" json:"similarSongs,omitempty"`
SimilarSongs2 *SimilarSongs2 `xml:"similarSongs2,omitempty" json:"similarSongs2,omitempty"`
TopSongs *TopSongs `xml:"topSongs,omitempty" json:"topSongs,omitempty"`
PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"`
Shares *Shares `xml:"shares,omitempty" json:"shares,omitempty"`
Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"`
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"`
InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"`
JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus,omitempty" json:"jukeboxStatus,omitempty"`
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
// OpenSubsonic extensions
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
}
const (
StatusOK = "ok"
StatusFailed = "failed"
)
type JsonWrapper struct {
Subsonic Subsonic `json:"subsonic-response"`
}
type Error struct {
Code int32 `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"`
}
type License struct {
Valid bool `xml:"valid,attr" json:"valid"`
}
type MusicFolder struct {
Id int32 `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
}
type MusicFolders struct {
Folders []MusicFolder `xml:"musicFolder" json:"musicFolder,omitempty"`
}
type Artist struct {
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
}
type Index struct {
Name string `xml:"name,attr" json:"name"`
Artists []Artist `xml:"artist" json:"artist"`
}
type Indexes struct {
Index []Index `xml:"index" json:"index,omitempty"`
LastModified int64 `xml:"lastModified,attr" json:"lastModified"`
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
}
type IndexID3 struct {
Name string `xml:"name,attr" json:"name"`
Artists []ArtistID3 `xml:"artist" json:"artist"`
}
type Artists struct {
Index []IndexID3 `xml:"index" json:"index,omitempty"`
LastModified int64 `xml:"lastModified,attr" json:"lastModified"`
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
}
type MediaType string
const (
MediaTypeSong MediaType = "song"
MediaTypeAlbum MediaType = "album"
MediaTypeArtist MediaType = "artist"
)
type Child struct {
Id string `xml:"id,attr" json:"id"`
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
IsDir bool `xml:"isDir,attr" json:"isDir"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
Track int32 `xml:"track,attr,omitempty" json:"track,omitempty"`
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"`
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
TranscodedContentType string `xml:"transcodedContentType,attr,omitempty" json:"transcodedContentType,omitempty"`
TranscodedSuffix string `xml:"transcodedSuffix,attr,omitempty" json:"transcodedSuffix,omitempty"`
Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"`
BitRate int32 `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"`
Path string `xml:"path,attr,omitempty" json:"path,omitempty"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
DiscNumber int32 `xml:"discNumber,attr,omitempty" json:"discNumber,omitempty"`
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
AlbumId string `xml:"albumId,attr,omitempty" json:"albumId,omitempty"`
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
*OpenSubsonicChild `xml:",omitempty" json:",omitempty"`
}
type OpenSubsonicChild struct {
// OpenSubsonic extensions
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
BPM int32 `xml:"bpm,attr,omitempty" json:"bpm"`
Comment string `xml:"comment,attr,omitempty" json:"comment"`
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
SamplingRate int32 `xml:"samplingRate,attr,omitempty" json:"samplingRate"`
BitDepth int32 `xml:"bitDepth,attr,omitempty" json:"bitDepth"`
Moods Array[string] `xml:"moods,omitempty" json:"moods"`
Artists Array[ArtistID3Ref] `xml:"artists,omitempty" json:"artists"`
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist"`
AlbumArtists Array[ArtistID3Ref] `xml:"albumArtists,omitempty" json:"albumArtists"`
DisplayAlbumArtist string `xml:"displayAlbumArtist,attr,omitempty" json:"displayAlbumArtist"`
Contributors Array[Contributor] `xml:"contributors,omitempty" json:"contributors"`
DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"`
ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"`
}
type Songs struct {
Songs []Child `xml:"song" json:"song,omitempty"`
}
type Directory struct {
Child []Child `xml:"child" json:"child,omitempty"`
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
// ID3
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
AlbumCount int32 `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
}
// ArtistID3Ref is a reference to an artist, a simplified version of ArtistID3. This is used to resolve the
// documentation conflict in OpenSubsonic: https://github.com/opensubsonic/open-subsonic-api/discussions/120
type ArtistID3Ref struct {
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
}
type ArtistID3 struct {
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
AlbumCount int32 `xml:"albumCount,attr" json:"albumCount"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
*OpenSubsonicArtistID3 `xml:",omitempty" json:",omitempty"`
}
type OpenSubsonicArtistID3 struct {
// OpenSubsonic extensions
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
Roles Array[string] `xml:"roles,omitempty" json:"roles"`
}
type AlbumID3 struct {
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
*OpenSubsonicAlbumID3 `xml:",omitempty" json:",omitempty"`
}
type OpenSubsonicAlbumID3 struct {
// OpenSubsonic extensions
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating"`
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
IsCompilation bool `xml:"isCompilation,attr,omitempty" json:"isCompilation"`
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
DiscTitles Array[DiscTitle] `xml:"discTitles,omitempty" json:"discTitles"`
OriginalReleaseDate ItemDate `xml:"originalReleaseDate,omitempty" json:"originalReleaseDate"`
ReleaseDate ItemDate `xml:"releaseDate,omitempty" json:"releaseDate"`
ReleaseTypes Array[string] `xml:"releaseTypes,omitempty" json:"releaseTypes"`
RecordLabels Array[RecordLabel] `xml:"recordLabels,omitempty" json:"recordLabels"`
Moods Array[string] `xml:"moods,omitempty" json:"moods"`
Artists Array[ArtistID3Ref] `xml:"artists,omitempty" json:"artists"`
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist"`
ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"`
Version string `xml:"version,attr,omitempty" json:"version"`
}
type ArtistWithAlbumsID3 struct {
ArtistID3
Album []AlbumID3 `xml:"album" json:"album,omitempty"`
}
type AlbumWithSongsID3 struct {
AlbumID3
Song []Child `xml:"song" json:"song,omitempty"`
}
type AlbumList struct {
Album []Child `xml:"album" json:"album,omitempty"`
}
type AlbumList2 struct {
Album []AlbumID3 `xml:"album" json:"album,omitempty"`
}
type Playlist struct {
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
SongCount int32 `xml:"songCount,attr" json:"songCount"`
Duration int32 `xml:"duration,attr" json:"duration"`
Public bool `xml:"public,attr" json:"public,omitempty"`
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
Created time.Time `xml:"created,attr" json:"created"`
Changed time.Time `xml:"changed,attr" json:"changed"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
*OpenSubsonicPlaylist `xml:",omitempty" json:",omitempty"`
/*
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
</xs:sequence>
*/
}
type OpenSubsonicPlaylist struct {
Readonly bool `xml:"readonly,attr,omitempty" json:"readonly"`
ValidUntil *time.Time `xml:"validUntil,attr,omitempty" json:"validUntil,omitempty"`
}
type Playlists struct {
Playlist []Playlist `xml:"playlist" json:"playlist,omitempty"`
}
type PlaylistWithSongs struct {
Playlist
Entry []Child `xml:"entry" json:"entry,omitempty"`
}
type SearchResult2 struct {
Artist []Artist `xml:"artist" json:"artist,omitempty"`
Album []Child `xml:"album" json:"album,omitempty"`
Song []Child `xml:"song" json:"song,omitempty"`
}
type SearchResult3 struct {
Artist []ArtistID3 `xml:"artist" json:"artist,omitempty"`
Album []AlbumID3 `xml:"album" json:"album,omitempty"`
Song []Child `xml:"song" json:"song,omitempty"`
}
type Starred struct {
Artist []Artist `xml:"artist" json:"artist,omitempty"`
Album []Child `xml:"album" json:"album,omitempty"`
Song []Child `xml:"song" json:"song,omitempty"`
}
type Starred2 struct {
Artist []ArtistID3 `xml:"artist" json:"artist,omitempty"`
Album []AlbumID3 `xml:"album" json:"album,omitempty"`
Song []Child `xml:"song" json:"song,omitempty"`
}
type NowPlayingEntry struct {
Child
UserName string `xml:"username,attr" json:"username"`
MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"`
PlayerId int32 `xml:"playerId,attr" json:"playerId"`
PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"`
}
type NowPlaying struct {
Entry []NowPlayingEntry `xml:"entry" json:"entry,omitempty"`
}
type User struct {
Username string `xml:"username,attr" json:"username"`
Email string `xml:"email,attr,omitempty" json:"email,omitempty"`
ScrobblingEnabled bool `xml:"scrobblingEnabled,attr" json:"scrobblingEnabled"`
MaxBitRate int32 `xml:"maxBitRate,attr,omitempty" json:"maxBitRate,omitempty"`
AdminRole bool `xml:"adminRole,attr" json:"adminRole"`
SettingsRole bool `xml:"settingsRole,attr" json:"settingsRole"`
DownloadRole bool `xml:"downloadRole,attr" json:"downloadRole"`
UploadRole bool `xml:"uploadRole,attr" json:"uploadRole"`
PlaylistRole bool `xml:"playlistRole,attr" json:"playlistRole"`
CoverArtRole bool `xml:"coverArtRole,attr" json:"coverArtRole"`
CommentRole bool `xml:"commentRole,attr" json:"commentRole"`
PodcastRole bool `xml:"podcastRole,attr" json:"podcastRole"`
StreamRole bool `xml:"streamRole,attr" json:"streamRole"`
JukeboxRole bool `xml:"jukeboxRole,attr" json:"jukeboxRole"`
ShareRole bool `xml:"shareRole,attr" json:"shareRole"`
VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"`
Folder []int32 `xml:"folder,omitempty" json:"folder,omitempty"`
}
type Users struct {
User []User `xml:"user" json:"user"`
}
type Genre struct {
Name string `xml:",chardata" json:"value,omitempty"`
SongCount int32 `xml:"songCount,attr" json:"songCount"`
AlbumCount int32 `xml:"albumCount,attr" json:"albumCount"`
}
type Genres struct {
Genre []Genre `xml:"genre,omitempty" json:"genre,omitempty"`
}
type AlbumInfo struct {
Notes string `xml:"notes,omitempty" json:"notes,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type ArtistInfoBase struct {
Biography string `xml:"biography,omitempty" json:"biography,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type ArtistInfo struct {
ArtistInfoBase
SimilarArtist []Artist `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type ArtistInfo2 struct {
ArtistInfoBase
SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type SimilarSongs struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type SimilarSongs2 struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type TopSongs struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type PlayQueue struct {
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
Username string `xml:"username,attr" json:"username"`
Changed time.Time `xml:"changed,attr" json:"changed"`
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
}
type PlayQueueByIndex struct {
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
Username string `xml:"username,attr" json:"username"`
Changed time.Time `xml:"changed,attr" json:"changed"`
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
}
type Bookmark struct {
Entry Child `xml:"entry,omitempty" json:"entry"`
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
Username string `xml:"username,attr" json:"username"`
Comment string `xml:"comment,attr" json:"comment"`
Created time.Time `xml:"created,attr" json:"created"`
Changed time.Time `xml:"changed,attr" json:"changed"`
}
type Bookmarks struct {
Bookmark []Bookmark `xml:"bookmark,omitempty" json:"bookmark,omitempty"`
}
type Share struct {
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
ID string `xml:"id,attr" json:"id"`
Url string `xml:"url,attr" json:"url"`
Description string `xml:"description,omitempty,attr" json:"description,omitempty"`
Username string `xml:"username,attr" json:"username"`
Created time.Time `xml:"created,attr" json:"created"`
Expires *time.Time `xml:"expires,omitempty,attr" json:"expires,omitempty"`
LastVisited *time.Time `xml:"lastVisited,omitempty,attr" json:"lastVisited,omitempty"`
VisitCount int32 `xml:"visitCount,attr" json:"visitCount"`
}
type Shares struct {
Share []Share `xml:"share,omitempty" json:"share,omitempty"`
}
type ScanStatus struct {
Scanning bool `xml:"scanning,attr" json:"scanning"`
Count int64 `xml:"count,attr" json:"count"`
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
Error string `xml:"error,attr,omitempty" json:"error,omitempty"`
ScanType string `xml:"scanType,attr,omitempty" json:"scanType,omitempty"`
ElapsedTime int64 `xml:"elapsedTime,attr,omitempty" json:"elapsedTime,omitempty"`
}
type Lyrics struct {
Artist string `xml:"artist,omitempty,attr" json:"artist,omitempty"`
Title string `xml:"title,omitempty,attr" json:"title,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type InternetRadioStations struct {
Radios []Radio `xml:"internetRadioStation" json:"internetRadioStation,omitempty"`
}
type Radio struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"`
HomepageUrl string `xml:"homePageUrl,omitempty,attr" json:"homePageUrl,omitempty"`
}
type JukeboxStatus struct {
CurrentIndex int32 `xml:"currentIndex,attr" json:"currentIndex"`
Playing bool `xml:"playing,attr" json:"playing"`
Gain float32 `xml:"gain,attr" json:"gain"`
Position int32 `xml:"position,omitempty,attr" json:"position"`
}
type JukeboxPlaylist struct {
JukeboxStatus
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
type Line struct {
Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type StructuredLyric struct {
DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"`
DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"`
Lang string `xml:"lang,attr" json:"lang"`
Line []Line `xml:"line" json:"line"`
Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"`
Synced bool `xml:"synced,attr" json:"synced"`
}
type StructuredLyrics []StructuredLyric
type LyricsList struct {
StructuredLyrics []StructuredLyric `xml:"structuredLyrics,omitempty" json:"structuredLyrics,omitempty"`
}
type OpenSubsonicExtension struct {
Name string `xml:"name,attr" json:"name"`
Versions []int32 `xml:"versions" json:"versions"`
}
type OpenSubsonicExtensions []OpenSubsonicExtension
type ItemGenre struct {
Name string `xml:"name,attr" json:"name"`
}
type ReplayGain struct {
TrackGain *float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"`
AlbumGain *float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"`
TrackPeak *float64 `xml:"trackPeak,omitempty,attr" json:"trackPeak,omitempty"`
AlbumPeak *float64 `xml:"albumPeak,omitempty,attr" json:"albumPeak,omitempty"`
BaseGain *float64 `xml:"baseGain,omitempty,attr" json:"baseGain,omitempty"`
FallbackGain *float64 `xml:"fallbackGain,omitempty,attr" json:"fallbackGain,omitempty"`
}
func (r ReplayGain) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if r.TrackGain == nil && r.AlbumGain == nil && r.TrackPeak == nil && r.AlbumPeak == nil && r.BaseGain == nil && r.FallbackGain == nil {
return nil
}
type replayGain ReplayGain
return e.EncodeElement(replayGain(r), start)
}
type DiscTitle struct {
Disc int32 `xml:"disc,attr" json:"disc"`
Title string `xml:"title,attr" json:"title"`
}
type ItemDate struct {
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
Month int32 `xml:"month,attr,omitempty" json:"month,omitempty"`
Day int32 `xml:"day,attr,omitempty" json:"day,omitempty"`
}
func (d ItemDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if d.Year == 0 && d.Month == 0 && d.Day == 0 {
return nil
}
type itemDate ItemDate
return e.EncodeElement(itemDate(d), start)
}
type RecordLabel struct {
Name string `xml:"name,attr" json:"name"`
}
type Contributor struct {
Role string `xml:"role,attr" json:"role"`
SubRole string `xml:"subRole,attr,omitempty" json:"subRole,omitempty"`
Artist ArtistID3Ref `xml:"artist" json:"artist"`
}
// Array is a generic type for marshalling slices to JSON. It is used to avoid marshalling empty slices as null.
type Array[T any] []T
func (a Array[T]) MarshalJSON() ([]byte, error) {
return marshalJSONArray(a)
}
// marshalJSONArray marshals a slice of any type to JSON. If the slice is empty, it is marshalled as an
// empty array instead of null.
func marshalJSONArray[T any](v []T) ([]byte, error) {
if len(v) == 0 {
return json.Marshal([]T{})
}
return json.Marshal(v)
}
// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension)
type TranscodeDecision struct {
CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"`
CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"`
TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"`
ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"`
TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"`
SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"`
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
}
// StreamDetails describes audio stream properties for transcoding decisions
type StreamDetails struct {
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`
Container string `xml:"container,attr,omitempty" json:"container,omitempty"`
Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"`
AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"`
AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"`
AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"`
AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"`
AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"`
}