Deluan Quintão 5927e693d1
feat(jellyfin): filter items by year and record label (#5817)
* feat(persistence): add AlbumRepository.GetYears for distinct album years

* test(persistence): verify GetYears de-duplicates repeated years

Regression test that adds two albums with the same non-zero max_year
(2005) and verifies that GetYears() returns that year exactly once,
ensuring the SQL DISTINCT clause is applied correctly. Catches any
future removal of DISTINCT from the GetYears query.

* feat(jellyfin): add legacy /Items/Filters endpoint (genres + years)

* feat(jellyfin): add /Studios endpoint from record label tags

* refactor(persistence): drop duplicate columns in tagRepository.GetAll

* fix(jellyfin): exclude missing albums from filter years

GetYears only filtered max_year > 0, so albums whose files were all removed
(missing=true, kept when Scanner.PurgeMissing=never) contributed stale years
to /Items/Filters. Filter them out like the normal album listings do. Also
return an empty slice from the MockAlbumRepo to match the real repository.

* feat(jellyfin): filter /Items by Years=

* feat(jellyfin): filter /Items by StudioIds= (record labels)

* feat(jellyfin): scope filter and studio lists to ParentId library

* refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers

Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call
sites and the duplicated empty-scope guard into two small helpers, so the
empty-scope=unrestricted contract lives in one place. Reuse the existing
names() helper in the Years= e2e test.

* feat(jellyfin): expose record labels as album Studios

Add a Studios field to the album BaseItemDto, populated from the record-label
tags and gated behind Fields=Studios (matching Jellyfin's ItemFields
convention). Studio ids reuse the record-label tag identity, so they round-trip
with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios
empty for music; Feishin reads it as the album's record label.
2026-07-19 12:39:31 -04:00

301 lines
16 KiB
Go

package dto
// PublicSystemInfo is the unauthenticated handshake payload (GET /System/Info/Public).
type PublicSystemInfo struct {
LocalAddress string `json:"LocalAddress,omitempty"`
ServerName string `json:"ServerName"`
Version string `json:"Version"`
ProductName string `json:"ProductName"`
OperatingSystem string `json:"OperatingSystem,omitempty"`
Id string `json:"Id"`
StartupWizardCompleted bool `json:"StartupWizardCompleted"`
}
// SystemInfo is the authenticated variant (GET /System/Info).
type SystemInfo struct {
PublicSystemInfo
HasPendingRestart bool `json:"HasPendingRestart"`
IsShuttingDown bool `json:"IsShuttingDown"`
SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"`
CachePath string `json:"CachePath,omitempty"`
}
type NameGuidPair struct {
Name string `json:"Name"`
Id string `json:"Id"`
}
type UserItemDataDto struct {
Rating *float64 `json:"Rating,omitempty"`
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayCount int `json:"PlayCount"`
IsFavorite bool `json:"IsFavorite"`
Played bool `json:"Played"`
Key string `json:"Key"`
ItemId string `json:"ItemId,omitempty"`
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
}
type BaseItemDto struct {
Name string `json:"Name"`
ServerId string `json:"ServerId,omitempty"`
Id string `json:"Id"`
// PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items),
// distinct from Id so a song appearing more than once can be removed by occurrence
// (DELETE .../Items?EntryIds=...) rather than by song id.
PlaylistItemId string `json:"PlaylistItemId,omitempty"`
Type string `json:"Type"`
IsFolder bool `json:"IsFolder"`
MediaType string `json:"MediaType,omitempty"`
CollectionType string `json:"CollectionType,omitempty"`
LocationType string `json:"LocationType,omitempty"`
HasLyrics bool `json:"HasLyrics,omitempty"`
SortName string `json:"SortName,omitempty"`
Path string `json:"Path,omitempty"`
ParentId string `json:"ParentId,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
IndexNumber *int `json:"IndexNumber,omitempty"`
ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"`
ProductionYear *int `json:"ProductionYear,omitempty"`
// PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side.
PremiereDate *string `json:"PremiereDate,omitempty"`
// DateCreated is the ISO 8601 date the item was added to the library; clients show it as
// "Date Added" and sort "Recently Added" by it.
DateCreated string `json:"DateCreated,omitempty"`
Album string `json:"Album,omitempty"`
AlbumId string `json:"AlbumId,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`
AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"`
AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"`
Artists []string `json:"Artists,omitempty"`
ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"`
Genres []string `json:"Genres,omitempty"`
GenreItems []NameGuidPair `json:"GenreItems,omitempty"`
Studios []NameGuidPair `json:"Studios,omitempty"`
NormalizationGain *float64 `json:"NormalizationGain,omitempty"`
AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"`
ChildCount *int `json:"ChildCount,omitempty"`
SongCount *int `json:"SongCount,omitempty"`
AlbumCount *int `json:"AlbumCount,omitempty"`
ImageTags map[string]string `json:"ImageTags,omitempty"`
// ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a
// de-dup key for image downloads (and a placeholder); absent, it warns the server isn't
// calculating blurhashes.
ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"`
BackdropImageTags []string `json:"BackdropImageTags"`
UserData *UserItemDataDto `json:"UserData,omitempty"`
MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"`
Container string `json:"Container,omitempty"`
CanDownload bool `json:"CanDownload"`
}
// PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which
// Finamp probes before allowing playlist edits.
type PlaylistUserPermissions struct {
UserId string `json:"UserId"`
CanEdit bool `json:"CanEdit"`
}
// PlaylistInfo is the response shape for GET /Playlists/{id}. ItemIds are media item ids, not
// playlist-entry ids (matching real Jellyfin); Finamp reads OpenAccess for the public-visibility toggle.
type PlaylistInfo struct {
OpenAccess bool `json:"OpenAccess"`
Shares []PlaylistUserPermissions `json:"Shares"`
ItemIds []string `json:"ItemIds"`
}
type QueryResult struct {
Items []BaseItemDto `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
StartIndex int `json:"StartIndex"`
}
type UserDto struct {
Name string `json:"Name"`
ServerId string `json:"ServerId,omitempty"`
ServerName string `json:"ServerName,omitempty"`
Id string `json:"Id"`
HasPassword bool `json:"HasPassword"`
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"`
PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
Policy *UserPolicy `json:"Policy,omitempty"`
Configuration *UserConfiguration `json:"Configuration,omitempty"`
}
// UserPolicy mirrors real Jellyfin's User.Policy. Finamp reads it right after login and crashes if
// it's absent, so every field must be present even though Navidrome lacks most of these concepts.
type UserPolicy struct {
IsAdministrator bool `json:"IsAdministrator"`
IsHidden bool `json:"IsHidden"`
EnableCollectionManagement bool `json:"EnableCollectionManagement"`
EnableSubtitleManagement bool `json:"EnableSubtitleManagement"`
EnableLyricManagement bool `json:"EnableLyricManagement"`
IsDisabled bool `json:"IsDisabled"`
BlockedTags []string `json:"BlockedTags"`
AllowedTags []string `json:"AllowedTags"`
EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"`
AccessSchedules []string `json:"AccessSchedules"`
BlockUnratedItems []string `json:"BlockUnratedItems"`
EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"`
EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"`
EnableRemoteAccess bool `json:"EnableRemoteAccess"`
EnableLiveTvManagement bool `json:"EnableLiveTvManagement"`
EnableLiveTvAccess bool `json:"EnableLiveTvAccess"`
EnableMediaPlayback bool `json:"EnableMediaPlayback"`
EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"`
EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"`
EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"`
ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"`
EnableContentDeletion bool `json:"EnableContentDeletion"`
EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders"`
EnableContentDownloading bool `json:"EnableContentDownloading"`
EnableSyncTranscoding bool `json:"EnableSyncTranscoding"`
EnableMediaConversion bool `json:"EnableMediaConversion"`
EnabledDevices []string `json:"EnabledDevices"`
EnableAllDevices bool `json:"EnableAllDevices"`
EnabledChannels []string `json:"EnabledChannels"`
EnableAllChannels bool `json:"EnableAllChannels"`
EnabledFolders []string `json:"EnabledFolders"`
EnableAllFolders bool `json:"EnableAllFolders"`
InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"`
LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"`
MaxActiveSessions int `json:"MaxActiveSessions"`
EnablePublicSharing bool `json:"EnablePublicSharing"`
BlockedMediaFolders []string `json:"BlockedMediaFolders"`
BlockedChannels []string `json:"BlockedChannels"`
RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"`
AuthenticationProviderId string `json:"AuthenticationProviderId"`
PasswordResetProviderId string `json:"PasswordResetProviderId"`
SyncPlayAccess string `json:"SyncPlayAccess"`
}
// UserConfiguration mirrors real Jellyfin's User.Configuration. Like UserPolicy, clients expect it
// always present, even though most settings don't apply to Navidrome's audio-only library.
type UserConfiguration struct {
PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"`
SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"`
DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"`
GroupedFolders []string `json:"GroupedFolders"`
SubtitleMode string `json:"SubtitleMode"`
DisplayCollectionsView bool `json:"DisplayCollectionsView"`
EnableLocalPassword bool `json:"EnableLocalPassword"`
OrderedViews []string `json:"OrderedViews"`
LatestItemsExcludes []string `json:"LatestItemsExcludes"`
MyMediaExcludes []string `json:"MyMediaExcludes"`
HidePlayedInLatest bool `json:"HidePlayedInLatest"`
RememberAudioSelections bool `json:"RememberAudioSelections"`
RememberSubtitleSelections bool `json:"RememberSubtitleSelections"`
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
CastReceiverId string `json:"CastReceiverId"`
}
type SessionInfo struct {
Id string `json:"Id"`
UserId string `json:"UserId"`
}
type AuthenticationResult struct {
User *UserDto `json:"User"`
SessionInfo *SessionInfo `json:"SessionInfo,omitempty"`
AccessToken string `json:"AccessToken"`
ServerId string `json:"ServerId"`
}
// MediaStream mirrors real Jellyfin's MediaStream. Finamp declares several bools as non-nullable, so
// they must always be emitted (no omitempty). Finamp also does MediaStreams.firstWhere((s) => s.type
// == 'Audio'), so MediaSourceInfo must include at least one Audio stream or that lookup throws.
type MediaStream struct {
Codec string `json:"Codec,omitempty"`
Type string `json:"Type"`
Index int `json:"Index"`
BitRate int `json:"BitRate,omitempty"`
Channels int `json:"Channels,omitempty"`
SampleRate int `json:"SampleRate,omitempty"`
ChannelLayout string `json:"ChannelLayout,omitempty"`
IsInterlaced bool `json:"IsInterlaced"`
IsDefault bool `json:"IsDefault"`
IsForced bool `json:"IsForced"`
IsExternal bool `json:"IsExternal"`
IsTextSubtitleStream bool `json:"IsTextSubtitleStream"`
SupportsExternalStream bool `json:"SupportsExternalStream"`
}
// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo. Finamp declares several bools/arrays as
// non-nullable, so a missing field deserializes to null and throws a cast error that aborts parsing
// of the whole item list; emit them always (no omitempty on bools).
type MediaSourceInfo struct {
Id string `json:"Id"`
Path string `json:"Path,omitempty"`
Protocol string `json:"Protocol"`
Container string `json:"Container,omitempty"`
TranscodingUrl string `json:"TranscodingUrl,omitempty"`
TranscodingSubProtocol string `json:"TranscodingSubProtocol,omitempty"`
Size int64 `json:"Size,omitempty"`
Name string `json:"Name,omitempty"`
IsRemote bool `json:"IsRemote"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
Bitrate int `json:"Bitrate,omitempty"`
SupportsTranscoding bool `json:"SupportsTranscoding"`
SupportsDirectStream bool `json:"SupportsDirectStream"`
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
Type string `json:"Type"`
ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"`
IgnoreDts bool `json:"IgnoreDts"`
IgnoreIndex bool `json:"IgnoreIndex"`
GenPtsInput bool `json:"GenPtsInput"`
IsInfiniteStream bool `json:"IsInfiniteStream"`
UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"`
RequiresOpening bool `json:"RequiresOpening"`
RequiresClosing bool `json:"RequiresClosing"`
RequiresLooping bool `json:"RequiresLooping"`
SupportsProbing bool `json:"SupportsProbing"`
HasSegments bool `json:"HasSegments"`
MediaStreams []MediaStream `json:"MediaStreams"`
MediaAttachments []any `json:"MediaAttachments"`
Formats []string `json:"Formats"`
}
type PlaybackInfoResponse struct {
MediaSources []MediaSourceInfo `json:"MediaSources"`
PlaySessionId string `json:"PlaySessionId"`
}
// LyricDto mirrors Jellyfin's GET /Audio/{itemId}/Lyrics response. Feishin and Jellify read only
// Lyrics[].Text and Start (ticks); Metadata is for completeness/Finamp.
type LyricDto struct {
Metadata LyricMetadata `json:"Metadata"`
Lyrics []LyricLine `json:"Lyrics"`
}
type LyricMetadata struct {
Artist string `json:"Artist,omitempty"`
Album string `json:"Album,omitempty"`
Title string `json:"Title,omitempty"`
Length int64 `json:"Length,omitempty"`
Offset *int64 `json:"Offset,omitempty"`
IsSynced bool `json:"IsSynced"`
}
type LyricLine struct {
Text string `json:"Text"`
Start *int64 `json:"Start,omitempty"`
Cues []LyricLineCue `json:"Cues,omitempty"`
}
type LyricLineCue struct {
Position int `json:"Position"`
EndPosition int `json:"EndPosition"`
Start int64 `json:"Start"`
End *int64 `json:"End,omitempty"`
}
// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present;
// clients (jellyfin-web) render each unconditionally.
type QueryFiltersLegacy struct {
Genres []string `json:"Genres"`
Tags []string `json:"Tags"`
OfficialRatings []string `json:"OfficialRatings"`
Years []int `json:"Years"`
}