feat: add Navidrome backend support with new adapter and unified API integration

This commit is contained in:
skalthoff
2025-12-16 12:46:12 -08:00
parent 7612684ba2
commit 752bc0c48b
35 changed files with 1480 additions and 315 deletions

View File

@@ -4,6 +4,8 @@
Jellify now supports **Navidrome** as a backend alongside Jellyfin using a unified adapter pattern. Playback, library browsing, and most core features work for both backends.
**Last Updated:** December 2024
---
## ✅ What's Working
@@ -12,22 +14,23 @@ Jellify now supports **Navidrome** as a backend alongside Jellyfin using a unifi
- **Audio streaming** via Subsonic `stream.view` endpoint with hex-encoded password auth
- **Track mapping** via `adapter.mapToJellifyTrack()` - each backend builds JellifyTrack with proper URLs
- **Queue loading** from all main entry points (tracks, albums, playlists, artists, home sections)
- **CarPlay playback** - adapter is now threaded through CarPlay components ✅ FIXED
### Library Browsing
| Feature | Status | Notes |
|---------|--------|-------|
| Albums | ✅ Working | `useAlbums` uses adapter for Navidrome |
| Tracks | ✅ Working | `useTracks` uses adapter for Navidrome |
| Artists | ✅ Working | Unified hooks |
| Playlists | ✅ Working | Unified hooks |
| Albums | ✅ Working | Unified adapter hooks |
| Tracks | ✅ Working | Unified adapter hooks |
| Artists | ✅ Working | Unified adapter hooks |
| Playlists | ✅ Working | Unified adapter hooks |
| Album Details | ✅ Working | `useAlbumDiscs` with disc grouping |
| Artist Details | ✅ Working | Uses adapter |
### Home Content
| Feature | Status | Notes |
|---------|--------|-------|
| Recently Played | ✅ Working | `useRecentlyPlayedTracks` |
| On Repeat | ✅ Working | `useFrequentlyPlayedTracks` |
| Recently Played | ✅ Working | Now uses adapter for both backends |
| On Repeat | ✅ Working | Now uses adapter for both backends |
| Recently Added Albums | ✅ Working | Unified hooks |
### Search
@@ -45,12 +48,18 @@ Jellify now supports **Navidrome** as a backend alongside Jellyfin using a unifi
### Playlists
| Feature | Status | Notes |
|---------|--------|-------|
| List Playlists | ✅ Working | |
| Get Playlist Tracks | ✅ Working | |
| List Playlists | ✅ Working | Now uses adapter |
| Get Playlist Tracks | ✅ Working | Now uses adapter |
| Create Playlist | ✅ Working | `createPlaylist.view` |
| Update Playlist | ✅ Working | |
| Delete Playlist | ✅ Working | |
### Downloads
| Feature | Status | Notes |
|---------|--------|-------|
| Download Tracks | ✅ Working | Uses `adapter.getDownloadUrl()` ✅ FIXED |
| Offline Playback | ✅ Working | |
---
## ⚠️ Partial/Limited Features
@@ -58,9 +67,9 @@ Jellify now supports **Navidrome** as a backend alongside Jellyfin using a unifi
### Playback Reporting
| Feature | Jellyfin | Navidrome | Notes |
|---------|----------|-----------|-------|
| Report Start | ✅ | ⏭️ Skipped | Subsonic has no equivalent |
| Report Progress | ✅ | ⏭️ Skipped | Subsonic has no equivalent |
| Report Stop | ✅ | ⏭️ Skipped | Subsonic has no equivalent |
| Report Start | ✅ | ⏭️ No-op | Subsonic has no equivalent (adapter handles gracefully) |
| Report Progress | ✅ | ⏭️ No-op | Subsonic has no equivalent (adapter handles gracefully) |
| Report Stop | ✅ | ⏭️ No-op | Subsonic has no equivalent (adapter handles gracefully) |
| Scrobbling | ✅ | ✅ | Uses `scrobble.view` on track complete |
### Instant Mix / Similar Tracks
@@ -71,17 +80,7 @@ Jellify now supports **Navidrome** as a backend alongside Jellyfin using a unifi
---
## ❌ Not Yet Implemented / Known Gaps
### CarPlay
- CarPlay components use a callback pattern for `loadNewQueue`
- Would require threading adapter through CarPlay navigation tree
- **Impact:** CarPlay playback on Navidrome won't use proper stream URLs
### Downloads
- Downloads use Jellyfin-specific URLs and caching
- Navidrome downloads may fail or use wrong URLs
- **Suggested fix:** Add `getDownloadUrl()` to adapter interface
## ❌ Known Limitations
### Transcoding
- Navidrome supports transcoding via `stream.view?format=xxx&maxBitRate=xxx`
@@ -97,6 +96,13 @@ Jellify now supports **Navidrome** as a backend alongside Jellyfin using a unifi
- Play count, last played timestamps sync via scrobbling
- But real-time "now playing" status isn't reported to server
### Public Playlists
- Jellyfin-specific concept, not available on Navidrome
### Artist "Featured On"
- Jellyfin-specific concept (albums where artist appears as guest)
- Not available on Navidrome
---
## Architecture Overview
@@ -110,8 +116,9 @@ Jellify now supports **Navidrome** as a backend alongside Jellyfin using a unifi
├─────────────────────────────────────────────────────────────┤
│ getAlbums(), getTracks(), getArtists(), getPlaylists() │
│ search(), star(), unstar(), getStarred() │
│ getStreamUrl(), getCoverArtUrl(), mapToJellifyTrack()
reportPlaybackStart/Progress/End()
│ getStreamUrl(), getCoverArtUrl(), getDownloadUrl()
mapToJellifyTrack()
│ reportPlaybackStart/Progress/End() (no-op for Navidrome) │
└─────────────────────────────────────────────────────────────┘
▲ ▲
│ │
@@ -120,24 +127,38 @@ Jellify now supports **Navidrome** as a backend alongside Jellyfin using a unifi
├─────────────────────────┤ ├───────────────────────┤
│ Uses @jellyfin/sdk │ │ Uses Subsonic API │
│ /Audio/{id}/stream │ │ /rest/stream.view │
│ /Audio/{id}/universal │ │ /rest/download.view │
│ X-Emby-Token header │ │ Auth params in URL │
│ Full playback reporting │ │ Scrobbling only │
└─────────────────────────┘ └───────────────────────┘
```
### Key Files Modified
### Query Migration Status
| File | Changes |
All major query hooks now use the adapter pattern, eliminating manual backend checks:
| Query Hook | Status |
|------------|--------|
| `useRecentlyPlayedTracks` | ✅ Uses adapter |
| `useRecentArtists` | ✅ Uses adapter |
| `useFrequentlyPlayedTracks` | ✅ Uses adapter |
| `useFrequentlyPlayedArtists` | ✅ Uses adapter |
| `useArtistAlbums` | ✅ Uses adapter |
| `useUserPlaylists` | ✅ Uses adapter |
| `usePlaylistTracks` | ✅ Uses adapter |
| `useTracks` | ✅ Uses adapter |
| `useAlbumArtists` | ✅ Uses adapter |
### Key Files
| File | Purpose |
|------|---------|
| `src/api/core/adapter.ts` | Added `mapToJellifyTrack()` method |
| `src/api/adapters/navidrome-adapter.ts` | Implemented all adapter methods |
| `src/api/adapters/jellyfin-adapter.ts` | Implemented `mapToJellifyTrack()` |
| `src/providers/Player/functions/queue.ts` | Added `mapTrackToJellify()` dispatcher |
| `src/providers/Player/interfaces.ts` | Added `adapter` to queue mutations |
| `src/api/queries/album/index.ts` | Enabled for Navidrome via adapter |
| `src/api/queries/track/index.ts` | Enabled for Navidrome via adapter |
| `src/api/mutations/playback/functions/*` | Skip Jellyfin calls for Navidrome |
| `src/components/*/` | Pass adapter to `loadNewQueue()` calls |
| `src/api/core/adapter.ts` | MusicServerAdapter interface |
| `src/api/adapters/navidrome-adapter.ts` | Navidrome/Subsonic implementation |
| `src/api/adapters/jellyfin-adapter.ts` | Jellyfin SDK wrapper |
| `src/api/adapters/*-mappings.ts` | Type conversion functions |
| `src/stores/index.ts` | `useAdapter()` hook |
| `src/utils/unified-conversions.ts` | UnifiedType → BaseItemDto converters |
### Track Mapping Flow
@@ -168,6 +189,15 @@ Audio plays! 🎵
---
## Test Coverage
Unit tests exist for adapter mappings:
- `jest/functional/adapters/navidrome-mappings.test.ts`
- `jest/functional/adapters/navidrome-adapter.test.ts`
- `jest/functional/adapters/jellyfin-adapter.test.ts`
---
## Testing Checklist
### Playback
@@ -181,6 +211,7 @@ Audio plays! 🎵
- [ ] Play from search results
- [ ] Queue: Play Next
- [ ] Queue: Play Later
- [ ] CarPlay playback
### Library
- [ ] Browse albums
@@ -190,6 +221,10 @@ Audio plays! 🎵
- [ ] Album disc grouping
- [ ] Infinite scroll pagination
### Downloads
- [ ] Download track
- [ ] Offline playback
### Other
- [ ] Favorites toggle
- [ ] Create playlist
@@ -200,24 +235,17 @@ Audio plays! 🎵
---
## Next Steps / Recommendations
## Remaining Improvements
1. **CarPlay Support**
- Thread adapter through CarPlay component tree
- Or store adapter in Zustand for global access
2. **Download Support**
- Add `getDownloadUrl(trackId, quality)` to adapter
- Update download manager to use adapter
3. **Quality Settings**
1. **Quality Settings**
- Add transcoding options to `mapToJellifyTrack`
- Respect user's streaming quality preference
4. **Error Handling**
2. **Error Handling**
- Add better error messages for Navidrome-specific failures
- Handle auth token expiry gracefully
5. **Testing**
- Add unit tests for adapters
- E2E tests for both backends
3. **Home Component Unification**
- Currently has separate `NavidromeHomeContent` and `JellyfinHomeContent`
- Could be unified using adapter hooks

View File

@@ -0,0 +1,195 @@
/**
* Unit tests for JellyfinAdapter class
*/
import { JellyfinAdapter } from '../../../src/api/adapters/jellyfin-adapter'
import {
mapJellyfinTrack,
mapJellyfinAlbum,
mapJellyfinArtist,
mapJellyfinPlaylist,
getJellyfinStreamUrl,
getJellyfinCoverArtUrl,
getJellyfinDownloadUrl,
} from '../../../src/api/adapters/jellyfin-mappings'
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { Api } from '@jellyfin/sdk'
import {
UnifiedTrack,
UnifiedAlbum,
UnifiedArtist,
UnifiedPlaylist,
} from '../../../src/api/core/types'
describe('jellyfin-mappings', () => {
describe('mapJellyfinTrack', () => {
it('should map a Jellyfin BaseItemDto (Audio) to UnifiedTrack', () => {
const jellyfinTrack: BaseItemDto = {
Id: 'track-123',
Name: 'Test Song',
Type: BaseItemKind.Audio,
AlbumId: 'album-456',
Album: 'Test Album',
ArtistItems: [{ Id: 'artist-789', Name: 'Test Artist' }],
Artists: ['Test Artist'],
RunTimeTicks: 1800000000, // 3 minutes in ticks
IndexNumber: 5,
ParentIndexNumber: 1,
ProductionYear: 2023,
Genres: ['Rock', 'Alternative'],
ImageBlurHashes: {
Primary: { 'album-456': 'L5Gk~Wof00ay00t7_3j[4nRj' },
},
NormalizationGain: -5.5,
}
const result = mapJellyfinTrack(jellyfinTrack)
expect(result.id).toBe('track-123')
expect(result.name).toBe('Test Song')
expect(result.albumId).toBe('album-456')
expect(result.albumName).toBe('Test Album')
expect(result.artistId).toBe('artist-789')
expect(result.artistName).toBe('Test Artist')
expect(result.duration).toBe(180) // Converted from ticks
expect(result.trackNumber).toBe(5)
expect(result.discNumber).toBe(1)
expect(result.year).toBe(2023)
expect(result.genre).toBe('Rock')
expect(result.normalizationGain).toBe(-5.5)
})
it('should handle track with multiple artists', () => {
const trackWithMultipleArtists: BaseItemDto = {
Id: 'track-1',
Name: 'Collab Song',
Type: BaseItemKind.Audio,
AlbumId: 'album-1',
Album: 'Album',
Artists: ['Artist 1', 'Artist 2', 'Artist 3'],
ArtistItems: [
{ Id: 'a1', Name: 'Artist 1' },
{ Id: 'a2', Name: 'Artist 2' },
],
RunTimeTicks: 1000000000,
}
const result = mapJellyfinTrack(trackWithMultipleArtists)
expect(result.artistName).toBe('Artist 1 • Artist 2 • Artist 3')
expect(result.artistId).toBe('a1')
})
it('should handle missing optional fields', () => {
const minimalTrack: BaseItemDto = {
Id: 'track-minimal',
Name: 'Minimal',
Type: BaseItemKind.Audio,
RunTimeTicks: 600000000,
}
const result = mapJellyfinTrack(minimalTrack)
expect(result.id).toBe('track-minimal')
expect(result.name).toBe('Minimal')
expect(result.albumId).toBe('')
expect(result.albumName).toBe('')
expect(result.artistId).toBe('')
expect(result.artistName).toBe('')
expect(result.trackNumber).toBeUndefined()
expect(result.discNumber).toBeUndefined()
})
})
describe('mapJellyfinAlbum', () => {
it('should map a Jellyfin BaseItemDto (MusicAlbum) to UnifiedAlbum', () => {
const jellyfinAlbum: BaseItemDto = {
Id: 'album-123',
Name: 'Test Album',
Type: BaseItemKind.MusicAlbum,
AlbumArtist: 'Test Artist',
AlbumArtists: [{ Id: 'artist-456', Name: 'Test Artist' }],
ProductionYear: 2022,
Genres: ['Pop'],
ChildCount: 12,
RunTimeTicks: 36000000000, // 1 hour
}
const result = mapJellyfinAlbum(jellyfinAlbum)
expect(result.id).toBe('album-123')
expect(result.name).toBe('Test Album')
expect(result.artistId).toBe('artist-456')
expect(result.artistName).toBe('Test Artist')
expect(result.year).toBe(2022)
expect(result.genre).toBe('Pop')
expect(result.trackCount).toBe(12)
expect(result.duration).toBe(3600) // Converted from ticks
})
})
describe('mapJellyfinArtist', () => {
it('should map a Jellyfin BaseItemDto (MusicArtist) to UnifiedArtist', () => {
const jellyfinArtist: BaseItemDto = {
Id: 'artist-123',
Name: 'Test Artist',
Type: BaseItemKind.MusicArtist,
ChildCount: 5,
Overview: 'Artist biography goes here',
}
const result = mapJellyfinArtist(jellyfinArtist)
expect(result.id).toBe('artist-123')
expect(result.name).toBe('Test Artist')
expect(result.albumCount).toBe(5)
expect(result.overview).toBe('Artist biography goes here')
})
})
describe('mapJellyfinPlaylist', () => {
it('should map a Jellyfin BaseItemDto (Playlist) to UnifiedPlaylist', () => {
const jellyfinPlaylist: BaseItemDto = {
Id: 'playlist-123',
Name: 'My Favorites',
Type: BaseItemKind.Playlist,
ChildCount: 50,
RunTimeTicks: 72000000000, // 2 hours
}
const result = mapJellyfinPlaylist(jellyfinPlaylist)
expect(result.id).toBe('playlist-123')
expect(result.name).toBe('My Favorites')
expect(result.trackCount).toBe(50)
expect(result.duration).toBe(7200) // Converted from ticks
})
})
})
describe('jellyfin-url-builders', () => {
const mockApi = {
basePath: 'https://jellyfin.example.com',
accessToken: 'test-token-123',
} as unknown as Api
describe('getJellyfinStreamUrl', () => {
it('should build correct stream URL', () => {
const url = getJellyfinStreamUrl(mockApi, 'track-123')
expect(url).toBe(
'https://jellyfin.example.com/Audio/track-123/universal?api_key=test-token-123',
)
})
})
describe('getJellyfinDownloadUrl', () => {
it('should build correct download URL', () => {
const url = getJellyfinDownloadUrl(mockApi, 'track-456')
expect(url).toBe(
'https://jellyfin.example.com/Audio/track-456/universal?api_key=test-token-123',
)
})
})
})

View File

@@ -0,0 +1,177 @@
/**
* Unit tests for NavidromeAdapter class
*/
// Mock react-native-nitro-fetch before importing the adapter
jest.mock('react-native-nitro-fetch', () => ({
nitroFetchOnWorklet: jest.fn(),
}))
import { NavidromeAdapter } from '../../../src/api/adapters/navidrome-adapter'
import { UnifiedTrack, StreamOptions } from '../../../src/api/core/types'
import { QueuingType } from '../../../src/enums/queuing-type'
describe('NavidromeAdapter', () => {
const serverUrl = 'https://navidrome.example.com'
const username = 'testuser'
const password = 'testpassword'
let adapter: NavidromeAdapter
beforeEach(() => {
adapter = new NavidromeAdapter(serverUrl, username, password)
})
describe('constructor', () => {
it('should create an adapter without errors', () => {
expect(adapter).toBeDefined()
})
})
describe('getStreamUrl', () => {
it('should return a properly formatted stream URL', () => {
const streamUrl = adapter.getStreamUrl('track-123')
expect(streamUrl).toContain('/rest/stream.view')
expect(streamUrl).toContain('id=track-123')
expect(streamUrl).toContain(`u=${username}`)
expect(streamUrl).toContain('p=enc%3A') // Hex-encoded password (: is URL-encoded as %3A)
expect(streamUrl).toContain('v=1.16.1')
expect(streamUrl).toContain('c=jellify')
expect(streamUrl).toContain('f=json')
})
it('should include maxBitRate when specified in options', () => {
const options: StreamOptions = { maxBitrate: 320 }
const streamUrl = adapter.getStreamUrl('track-123', options)
expect(streamUrl).toContain('maxBitRate=320')
})
it('should include format when specified in options', () => {
const options: StreamOptions = { format: 'opus' }
const streamUrl = adapter.getStreamUrl('track-123', options)
expect(streamUrl).toContain('format=opus')
})
})
describe('getCoverArtUrl', () => {
it('should return a properly formatted cover art URL', () => {
const coverArtUrl = adapter.getCoverArtUrl('album-456')
expect(coverArtUrl).toContain('/rest/getCoverArt.view')
expect(coverArtUrl).toContain('id=album-456')
expect(coverArtUrl).toContain(`u=${username}`)
})
it('should include size when specified', () => {
const coverArtUrl = adapter.getCoverArtUrl('album-456', 500)
expect(coverArtUrl).toContain('size=500')
})
})
describe('getDownloadUrl', () => {
it('should return a properly formatted download URL', () => {
const downloadUrl = adapter.getDownloadUrl('track-123')
expect(downloadUrl).toContain('/rest/download.view')
expect(downloadUrl).toContain('id=track-123')
expect(downloadUrl).toContain(`u=${username}`)
})
})
describe('mapToJellifyTrack', () => {
it('should map a UnifiedTrack to JellifyTrack', () => {
const unifiedTrack: UnifiedTrack = {
id: 'track-123',
name: 'Test Song',
albumId: 'album-456',
albumName: 'Test Album',
artistId: 'artist-789',
artistName: 'Test Artist',
duration: 180,
trackNumber: 3,
discNumber: 1,
coverArtId: 'cover-abc',
}
const result = adapter.mapToJellifyTrack(unifiedTrack, QueuingType.FromSelection)
expect(result.title).toBe('Test Song')
expect(result.album).toBe('Test Album')
expect(result.artist).toBe('Test Artist')
expect(result.duration).toBe(180)
expect(result.backend).toBe('navidrome')
expect(result.sessionId).toBeNull() // Navidrome doesn't use session IDs
expect(result.sourceType).toBe('stream')
expect(result.QueuingType).toBe(QueuingType.FromSelection)
expect(result.url).toContain('/rest/stream.view')
expect(result.artwork).toContain('/rest/getCoverArt.view')
})
it('should handle track without cover art', () => {
const trackWithoutCover: UnifiedTrack = {
id: 'track-no-cover',
name: 'No Cover Song',
albumId: 'album-id',
albumName: 'Album',
artistId: 'artist-id',
artistName: 'Artist',
duration: 120,
}
const result = adapter.mapToJellifyTrack(trackWithoutCover)
expect(result.artwork).toBeUndefined()
})
it('should include item with BaseItemDto-compatible structure', () => {
const track: UnifiedTrack = {
id: 'track-1',
name: 'Song',
albumId: 'album-1',
albumName: 'Album',
artistId: 'artist-1',
artistName: 'Artist',
duration: 100,
normalizationGain: -5.5,
}
const result = adapter.mapToJellifyTrack(track)
expect(result.item.Id).toBe('track-1')
expect(result.item.Name).toBe('Song')
expect(result.item.AlbumId).toBe('album-1')
expect(result.item.NormalizationGain).toBe(-5.5)
expect(result.item.RunTimeTicks).toBe(100 * 10_000_000)
})
})
describe('reportPlaybackStart', () => {
it('should resolve without error (no-op for Navidrome)', async () => {
await expect(adapter.reportPlaybackStart('track-123')).resolves.toBeUndefined()
})
})
describe('reportPlaybackProgress', () => {
it('should resolve without error (no-op for Navidrome)', async () => {
await expect(adapter.reportPlaybackProgress('track-123', 60)).resolves.toBeUndefined()
})
})
describe('URL encoding', () => {
it('should properly hex-encode password in URLs', () => {
const adapterWithSpecialChars = new NavidromeAdapter(serverUrl, 'user', 'p@ss!w0rd')
const streamUrl = adapterWithSpecialChars.getStreamUrl('track-1')
// Should contain hex-encoded password
expect(streamUrl).toContain('p=enc%3A') // Hex-encoded password (: is URL-encoded)
// Should NOT contain raw special characters
expect(streamUrl).not.toContain('@')
expect(streamUrl).not.toContain('!')
})
})
})

View File

@@ -0,0 +1,288 @@
/**
* Unit tests for Navidrome type mapping functions
*/
import {
mapSubsonicTrack,
mapSubsonicAlbum,
mapSubsonicArtist,
mapSubsonicPlaylist,
mapSubsonicSearchResults,
mapSubsonicStarred,
} from '../../../src/api/adapters/navidrome-mappings'
import {
UnifiedTrack,
UnifiedAlbum,
UnifiedArtist,
UnifiedPlaylist,
} from '../../../src/api/core/types'
describe('navidrome-mappings', () => {
describe('mapSubsonicTrack', () => {
it('should map a basic subsonic track to UnifiedTrack', () => {
const subsonicTrack = {
id: 'track-123',
title: 'Test Song',
album: 'Test Album',
artist: 'Test Artist',
albumId: 'album-456',
artistId: 'artist-789',
duration: 180,
track: 5,
discNumber: 1,
year: 2023,
genre: 'Rock',
coverArt: 'cover-abc',
}
const result = mapSubsonicTrack(subsonicTrack)
expect(result.id).toBe('track-123')
expect(result.name).toBe('Test Song')
expect(result.albumId).toBe('album-456')
expect(result.albumName).toBe('Test Album')
expect(result.artistId).toBe('artist-789')
expect(result.artistName).toBe('Test Artist')
expect(result.duration).toBe(180)
expect(result.trackNumber).toBe(5)
expect(result.discNumber).toBe(1)
expect(result.year).toBe(2023)
expect(result.genre).toBe('Rock')
expect(result.coverArtId).toBe('cover-abc')
})
it('should handle missing optional fields', () => {
const minimalTrack = {
id: 'track-minimal',
title: 'Minimal Track',
album: 'Album',
artist: 'Artist',
albumId: 'album-id',
artistId: 'artist-id',
duration: 120,
}
const result = mapSubsonicTrack(minimalTrack)
expect(result.id).toBe('track-minimal')
expect(result.name).toBe('Minimal Track')
expect(result.trackNumber).toBeUndefined()
expect(result.discNumber).toBeUndefined()
expect(result.year).toBeUndefined()
expect(result.genre).toBeUndefined()
// coverArtId falls back to albumId when coverArt is missing
expect(result.coverArtId).toBe('album-id')
})
it('should fallback to empty string when artist is missing', () => {
const trackWithoutArtist = {
id: 'track-1',
title: 'Song',
album: 'Album Name',
// artist is undefined
albumId: 'album-1',
// artistId is undefined
duration: 100,
}
const result = mapSubsonicTrack(trackWithoutArtist)
expect(result.artistName).toBe('')
expect(result.artistId).toBe('')
})
it('should use id as coverArtId fallback when both coverArt and albumId are missing', () => {
const trackMinimal = {
id: 'track-only-id',
}
const result = mapSubsonicTrack(trackMinimal)
expect(result.coverArtId).toBe('track-only-id')
})
})
describe('mapSubsonicAlbum', () => {
it('should map a subsonic album to UnifiedAlbum', () => {
const subsonicAlbum = {
id: 'album-123',
name: 'Test Album',
artist: 'Test Artist',
artistId: 'artist-456',
year: 2022,
genre: 'Pop',
songCount: 12,
duration: 3600,
coverArt: 'cover-xyz',
}
const result = mapSubsonicAlbum(subsonicAlbum)
expect(result.id).toBe('album-123')
expect(result.name).toBe('Test Album')
expect(result.artistId).toBe('artist-456')
expect(result.artistName).toBe('Test Artist')
expect(result.year).toBe(2022)
expect(result.genre).toBe('Pop')
expect(result.trackCount).toBe(12)
expect(result.duration).toBe(3600)
expect(result.coverArtId).toBe('cover-xyz')
})
it('should handle missing optional fields', () => {
const minimalAlbum = {
id: 'album-minimal',
name: 'Minimal Album',
artist: 'Artist',
artistId: 'artist-id',
}
const result = mapSubsonicAlbum(minimalAlbum)
expect(result.id).toBe('album-minimal')
expect(result.year).toBeUndefined()
expect(result.genre).toBeUndefined()
expect(result.trackCount).toBeUndefined()
// coverArtId falls back to album id
expect(result.coverArtId).toBe('album-minimal')
})
})
describe('mapSubsonicArtist', () => {
it('should map a subsonic artist to UnifiedArtist', () => {
const subsonicArtist = {
id: 'artist-123',
name: 'Test Artist',
albumCount: 5,
coverArt: 'artist-cover',
}
const result = mapSubsonicArtist(subsonicArtist)
expect(result.id).toBe('artist-123')
expect(result.name).toBe('Test Artist')
expect(result.albumCount).toBe(5)
expect(result.coverArtId).toBe('artist-cover')
})
it('should fallback to id when coverArt is missing', () => {
const artistWithoutCover = {
id: 'artist-1',
name: 'Artist',
}
const result = mapSubsonicArtist(artistWithoutCover)
expect(result.coverArtId).toBe('artist-1')
})
})
describe('mapSubsonicPlaylist', () => {
it('should map a subsonic playlist to UnifiedPlaylist', () => {
const subsonicPlaylist = {
id: 'playlist-123',
name: 'My Playlist',
songCount: 25,
duration: 5400,
public: true,
owner: 'user1',
coverArt: 'playlist-cover',
}
const result = mapSubsonicPlaylist(subsonicPlaylist)
expect(result.id).toBe('playlist-123')
expect(result.name).toBe('My Playlist')
expect(result.trackCount).toBe(25)
expect(result.duration).toBe(5400)
expect(result.isPublic).toBe(true)
expect(result.ownerName).toBe('user1')
expect(result.coverArtId).toBe('playlist-cover')
})
it('should default trackCount to 0 when songCount is missing', () => {
const minimalPlaylist = {
id: 'playlist-1',
name: 'Empty Playlist',
}
const result = mapSubsonicPlaylist(minimalPlaylist)
expect(result.trackCount).toBe(0)
})
})
describe('mapSubsonicSearchResults', () => {
it('should map search results into categorized arrays', () => {
const searchResult = {
artist: [{ id: 'a1', name: 'Artist 1' }],
album: [{ id: 'al1', name: 'Album 1', artist: 'Artist', artistId: 'a1' }],
song: [
{
id: 's1',
title: 'Song 1',
album: 'Album',
artist: 'Artist',
albumId: 'al1',
artistId: 'a1',
duration: 100,
},
],
}
const result = mapSubsonicSearchResults(searchResult)
expect(result.artists).toHaveLength(1)
expect(result.albums).toHaveLength(1)
expect(result.tracks).toHaveLength(1)
expect(result.playlists).toHaveLength(0) // Subsonic search doesn't include playlists
})
it('should handle empty search results', () => {
const emptyResult = {}
const result = mapSubsonicSearchResults(emptyResult)
expect(result.artists).toHaveLength(0)
expect(result.albums).toHaveLength(0)
expect(result.tracks).toHaveLength(0)
expect(result.playlists).toHaveLength(0)
})
})
describe('mapSubsonicStarred', () => {
it('should map starred content', () => {
const starred = {
artist: [{ id: 'a1', name: 'Starred Artist' }],
album: [{ id: 'al1', name: 'Starred Album', artist: 'Artist', artistId: 'a1' }],
song: [
{
id: 's1',
title: 'Starred Song',
album: 'Album',
artist: 'Artist',
albumId: 'al1',
artistId: 'a1',
duration: 100,
},
],
}
const result = mapSubsonicStarred(starred)
expect(result.artists).toHaveLength(1)
expect(result.albums).toHaveLength(1)
expect(result.tracks).toHaveLength(1)
})
it('should handle empty starred result', () => {
const emptyStarred = {}
const result = mapSubsonicStarred(emptyStarred)
expect(result.artists).toHaveLength(0)
expect(result.albums).toHaveLength(0)
expect(result.tracks).toHaveLength(0)
})
})
})

View File

@@ -48,6 +48,7 @@ import {
mapJellyfinTracks,
getJellyfinCoverArtUrl,
getJellyfinStreamUrl,
getJellyfinDownloadUrl,
} from './jellyfin-mappings'
import JellifyTrack from '../../types/JellifyTrack'
import { QueuingType } from '../../enums/queuing-type'
@@ -56,6 +57,8 @@ import { QueuingType } from '../../enums/queuing-type'
* Jellyfin implementation of MusicServerAdapter.
*/
export class JellyfinAdapter implements MusicServerAdapter {
readonly backend = 'jellyfin' as const
constructor(
private api: Api,
private userId: string,
@@ -348,7 +351,15 @@ export class JellyfinAdapter implements MusicServerAdapter {
return getJellyfinCoverArtUrl(this.api, id, size)
}
mapToJellifyTrack(track: UnifiedTrack, queuingType?: QueuingType): JellifyTrack {
getDownloadUrl(trackId: string): string {
return getJellyfinDownloadUrl(this.api, trackId)
}
mapToJellifyTrack(
track: UnifiedTrack,
queuingType?: QueuingType,
_streamOptions?: StreamOptions, // Jellyfin uses device profiles, not URL params
): JellifyTrack {
const streamUrl = this.getStreamUrl(track.id)
const coverArtUrl = track.coverArtId
? this.getCoverArtUrl(track.coverArtId, 500)

View File

@@ -211,3 +211,11 @@ export function getJellyfinCoverArtUrl(api: Api, id: string, size?: number): str
export function getJellyfinStreamUrl(api: Api, trackId: string): string {
return `${api.basePath}/Audio/${trackId}/universal?api_key=${api.accessToken}`
}
/**
* Get the download URL for a Jellyfin track.
* Uses the same universal endpoint as streaming.
*/
export function getJellyfinDownloadUrl(api: Api, trackId: string): string {
return `${api.basePath}/Audio/${trackId}/universal?api_key=${api.accessToken}`
}

View File

@@ -48,6 +48,8 @@ function hexEncode(str: string): string {
* This avoids the crypto dependency that subsonic-api requires.
*/
export class NavidromeAdapter implements MusicServerAdapter {
readonly backend = 'navidrome' as const
private serverUrl: string
private username: string
private password: string
@@ -409,9 +411,16 @@ export class NavidromeAdapter implements MusicServerAdapter {
if (size) params.size = String(size)
return this.buildUrl('getCoverArt.view', params)
}
getDownloadUrl(trackId: string): string {
return this.buildUrl('download.view', { id: trackId })
}
mapToJellifyTrack(track: UnifiedTrack, queuingType?: QueuingType): JellifyTrack {
const streamUrl = this.getStreamUrl(track.id)
mapToJellifyTrack(
track: UnifiedTrack,
queuingType?: QueuingType,
streamOptions?: StreamOptions,
): JellifyTrack {
const streamUrl = this.getStreamUrl(track.id, streamOptions)
const coverArtUrl = track.coverArtId
? this.getCoverArtUrl(track.coverArtId, 500)
: undefined
@@ -438,6 +447,8 @@ export class NavidromeAdapter implements MusicServerAdapter {
sessionId: null, // Navidrome doesn't use session IDs
sourceType: 'stream',
QueuingType: queuingType,
// Store stream options for quality badge display
navidromeStreamOptions: streamOptions,
// No headers needed - auth is in the URL
} as JellifyTrack
}

View File

@@ -25,6 +25,11 @@ import { QueuingType } from '../../enums/queuing-type'
* All backend-specific implementations must conform to this interface.
*/
export interface MusicServerAdapter {
/**
* The backend type this adapter is for.
*/
readonly backend: 'jellyfin' | 'navidrome'
// =========================================================================
// Connection & Authentication
// =========================================================================
@@ -174,14 +179,26 @@ export interface MusicServerAdapter {
*/
getCoverArtUrl(id: string, size?: number): string
/**
* Get the download URL for a track.
* Used for offline playback/caching.
* @param trackId The track ID to download
*/
getDownloadUrl(trackId: string): string
/**
* Map a track to a JellifyTrack for playback.
* Each backend implements this differently based on its stream URL format.
* @param track The unified track or BaseItemDto to map
* @param queuingType The type of queuing being performed
* @param streamOptions Optional streaming options (quality, format)
* @returns A JellifyTrack ready for RNTP
*/
mapToJellifyTrack(track: UnifiedTrack, queuingType?: QueuingType): JellifyTrack
mapToJellifyTrack(
track: UnifiedTrack,
queuingType?: QueuingType,
streamOptions?: StreamOptions,
): JellifyTrack
/**
* Report that playback has started.

View File

@@ -224,3 +224,24 @@ export interface StreamOptions {
/** Audio format (e.g., 'mp3', 'opus', 'raw') */
format?: string
}
/**
* Convert a StreamingQuality enum value to StreamOptions for Subsonic API.
* Maps quality settings to maxBitRate and format params.
*/
export function streamingQualityToStreamOptions(
quality: 'original' | 'high' | 'medium' | 'low',
): StreamOptions {
switch (quality) {
case 'original':
return { format: 'raw' } // Direct stream, no transcoding
case 'high':
return { maxBitrate: 320, format: 'mp3' }
case 'medium':
return { maxBitrate: 192, format: 'mp3' }
case 'low':
return { maxBitrate: 128, format: 'mp3' }
default:
return { format: 'raw' }
}
}

View File

@@ -6,13 +6,16 @@ import { deleteAudio, saveAudio } from './offlineModeUtils'
import { useState } from 'react'
import { JellifyDownloadProgress } from '../../../types/JellifyDownload'
import { useAllDownloadedTracks } from '../../queries/download'
import { useApi } from '../../../stores'
import { useApi, useAdapter, useJellifyServer } from '../../../stores'
import { QueuingType } from '../../../enums/queuing-type'
export const useDownloadAudioItem: () => [
JellifyDownloadProgress,
UseMutateFunction<boolean, Error, { item: BaseItemDto; autoCached: boolean }, void>,
] = () => {
const api = useApi()
const adapter = useAdapter()
const [server] = useJellifyServer()
const { data: downloadedTracks, refetch } = useAllDownloadedTracks()
@@ -31,8 +34,6 @@ export const useDownloadAudioItem: () => [
item: BaseItemDto
autoCached: boolean
}) => {
if (!api) return Promise.reject('API Instance not set')
// If we already have this track downloaded, resolve the promise
if (
downloadedTracks?.filter((download) => download.item.Id === item.Id).length ??
@@ -40,16 +41,43 @@ export const useDownloadAudioItem: () => [
)
return Promise.resolve(false)
// For Navidrome, use the adapter to create the track with proper URLs
if (server?.backend === 'navidrome' && adapter && item.Id) {
// Convert BaseItemDto to unified track format
const unifiedTrack = {
id: item.Id,
name: item.Name ?? 'Unknown',
albumId: item.AlbumId ?? '',
albumName: item.Album ?? '',
artistId: item.ArtistItems?.[0]?.Id ?? '',
artistName: item.Artists?.join(' • ') ?? '',
duration: item.RunTimeTicks ? item.RunTimeTicks / 10_000_000 : 0,
trackNumber: item.IndexNumber ?? undefined,
discNumber: item.ParentIndexNumber ?? undefined,
coverArtId: item.AlbumId ?? item.Id,
}
// Get track with stream URL, then override with download URL
const track = adapter.mapToJellifyTrack(
unifiedTrack,
QueuingType.DirectlyQueued,
)
// Use download.view instead of stream.view for actual file download
track.url = adapter.getDownloadUrl(item.Id)
return saveAudio(track, setDownloadProgress, autoCached)
}
// For Jellyfin, use the existing mapper
if (!api) return Promise.reject('API Instance not set')
const track = mapDtoToTrack(api, item, deviceProfile)
return saveAudio(track, setDownloadProgress, autoCached)
},
onError: (error) =>
console.error('Downloading audio track from Jellyfin failed', error),
onError: (error) => console.error('Downloading audio track failed', error),
onSuccess: (data) =>
console.error(
`${data ? 'Downloaded' : 'Did not download'} audio track from Jellyfin`,
),
console.log(`${data ? 'Downloaded' : 'Did not download'} audio track`),
onSettled: () => refetch(),
}).mutate,
]

View File

@@ -2,16 +2,25 @@ import { Api } from '@jellyfin/sdk'
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
import { AxiosResponse } from 'axios'
import useJellifyStore from '../../../../stores'
import { MusicServerAdapter } from '../../../core/adapter'
import { convertRunTimeTicksToSeconds } from '../../../../utils/runtimeticks'
export default async function reportPlaybackCompleted(
api: Api | undefined,
track: JellifyTrack,
adapter?: MusicServerAdapter,
): Promise<AxiosResponse<void, unknown> | void> {
// Skip for Navidrome - it doesn't have playstate reporting endpoints
const server = useJellifyStore.getState().server
if (server?.backend === 'navidrome') return Promise.resolve()
// If adapter is provided, delegate to it (handles scrobbling for Navidrome)
if (adapter) {
const duration = track.mediaSourceInfo?.RunTimeTicks
? convertRunTimeTicksToSeconds(track.mediaSourceInfo.RunTimeTicks)
: track.item.RunTimeTicks
? convertRunTimeTicksToSeconds(track.item.RunTimeTicks)
: 0
return adapter.reportPlaybackEnd(track.item.Id!, duration, true)
}
// Legacy Jellyfin path
if (!api) return Promise.reject('API instance not set')
const { sessionId, item, mediaSourceInfo } = track

View File

@@ -3,17 +3,20 @@ import { convertSecondsToRunTimeTicks } from '../../../../utils/runtimeticks'
import { Api } from '@jellyfin/sdk'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import { AxiosResponse } from 'axios'
import useJellifyStore from '../../../../stores'
import { MusicServerAdapter } from '../../../core/adapter'
export default async function reportPlaybackProgress(
api: Api | undefined,
track: JellifyTrack,
position: number,
adapter?: MusicServerAdapter,
): Promise<AxiosResponse<void, unknown> | void> {
// Skip for Navidrome - it doesn't have playstate reporting endpoints
const server = useJellifyStore.getState().server
if (server?.backend === 'navidrome') return Promise.resolve()
// If adapter is provided, delegate to it (handles no-ops for Navidrome)
if (adapter) {
return adapter.reportPlaybackProgress(track.item.Id!, position)
}
// Legacy Jellyfin path
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track

View File

@@ -1,13 +1,19 @@
import { Api } from '@jellyfin/sdk'
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api'
import useJellifyStore from '../../../../stores'
import { MusicServerAdapter } from '../../../core/adapter'
export default async function reportPlaybackStarted(api: Api | undefined, track: JellifyTrack) {
// Skip for Navidrome - it doesn't have playstate reporting endpoints
const server = useJellifyStore.getState().server
if (server?.backend === 'navidrome') return Promise.resolve()
export default async function reportPlaybackStarted(
api: Api | undefined,
track: JellifyTrack,
adapter?: MusicServerAdapter,
) {
// If adapter is provided, delegate to it (handles no-ops for Navidrome)
if (adapter) {
return adapter.reportPlaybackStart(track.item.Id!)
}
// Legacy Jellyfin path
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track

View File

@@ -2,16 +2,20 @@ import { Api } from '@jellyfin/sdk'
import JellifyTrack from '../../../../types/JellifyTrack'
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
import { AxiosResponse } from 'axios'
import useJellifyStore from '../../../../stores'
import { MusicServerAdapter } from '../../../core/adapter'
export default async function reportPlaybackStopped(
api: Api | undefined,
track: JellifyTrack,
position: number = 0,
adapter?: MusicServerAdapter,
): Promise<AxiosResponse<void, unknown> | void> {
// Skip for Navidrome - it doesn't have playstate reporting endpoints
const server = useJellifyStore.getState().server
if (server?.backend === 'navidrome') return Promise.resolve()
// If adapter is provided, delegate to it (handles no-ops for Navidrome)
if (adapter) {
return adapter.reportPlaybackEnd(track.item.Id!, position, false)
}
// Legacy Jellyfin path
if (!api) return Promise.reject('API instance not set')
const { sessionId, item } = track

View File

@@ -34,25 +34,43 @@ function unifiedArtistToBaseItem(artist: UnifiedArtist): BaseItemDto {
export const useArtistAlbums = (artist: BaseItemDto) => {
const api = useApi()
const adapter = useAdapter()
const [library] = useJellifyLibrary()
const [server] = useJellifyServer()
// Only run for Jellyfin backend - Navidrome uses adapter hooks
const isJellyfin = server?.backend !== 'navidrome'
return useQuery({
queryKey: [QueryKeys.ArtistAlbums, library?.musicLibraryId, artist.Id],
queryFn: () => fetchArtistAlbums(api, library?.musicLibraryId, artist),
enabled: isJellyfin && !isUndefined(artist.Id),
queryFn: async () => {
// Use adapter if available
if (adapter && artist.Id) {
const albums = await adapter.getArtistAlbums(artist.Id)
// Convert to BaseItemDto format for compatibility
return albums.map(
(album) =>
({
Id: album.id,
Name: album.name,
Type: 'MusicAlbum',
AlbumArtist: album.artistName,
ProductionYear: album.year,
ImageTags: album.coverArtId ? { Primary: album.coverArtId } : undefined,
}) as BaseItemDto,
)
}
// Fallback to Jellyfin-specific fetch
return fetchArtistAlbums(api, library?.musicLibraryId, artist)
},
enabled: !!adapter && !isUndefined(artist.Id),
})
}
// Note: Featured On is a Jellyfin-specific concept (albums where artist appears as guest)
// Navidrome doesn't have this distinction, so we keep backend check here
export const useArtistFeaturedOn = (artist: BaseItemDto) => {
const api = useApi()
const [library] = useJellifyLibrary()
const [server] = useJellifyServer()
// Only run for Jellyfin backend - Navidrome uses adapter hooks
// Only run for Jellyfin backend - Navidrome doesn't have this concept
const isJellyfin = server?.backend !== 'navidrome'
return useQuery({

View File

@@ -3,7 +3,11 @@ import { FrequentlyPlayedArtistsQueryKey, FrequentlyPlayedTracksQueryKey } from
import { fetchFrequentlyPlayed, fetchFrequentlyPlayedArtists } from './utils/frequents'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { isUndefined } from 'lodash'
import { useApi, useJellifyLibrary, useJellifyUser, useJellifyServer } from '../../../stores'
import { useApi, useJellifyLibrary, useJellifyUser, useAdapter } from '../../../stores'
import {
unifiedTracksToBaseItems,
unifiedArtistsToBaseItems,
} from '../../../utils/unified-conversions'
const FREQUENTS_QUERY_CONFIG = {
maxPages: MaxPages.Home,
@@ -12,46 +16,62 @@ const FREQUENTS_QUERY_CONFIG = {
export const useFrequentlyPlayedTracks = () => {
const api = useApi()
const adapter = useAdapter()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const [server] = useJellifyServer()
// Only run for Jellyfin backend - Navidrome uses different queries
const isJellyfin = server?.backend !== 'navidrome'
return useInfiniteQuery({
queryKey: FrequentlyPlayedTracksQueryKey(user, library),
queryFn: ({ pageParam }) => fetchFrequentlyPlayed(api, library, pageParam),
queryFn: async ({ pageParam }) => {
// Use adapter for both backends (if method exists)
if (adapter?.getFrequentTracks) {
const tracks = await adapter.getFrequentTracks(ApiLimits.Home * (pageParam + 1))
// Paginate the results client-side
const startIndex = pageParam * ApiLimits.Home
const paginatedTracks = tracks.slice(startIndex, startIndex + ApiLimits.Home)
return unifiedTracksToBaseItems(paginatedTracks)
}
// Fallback to Jellyfin-specific fetch
return fetchFrequentlyPlayed(api, library, pageParam)
},
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === ApiLimits.Home ? lastPageParam + 1 : undefined
},
enabled: isJellyfin,
enabled: !!adapter?.getFrequentTracks,
...FREQUENTS_QUERY_CONFIG,
})
}
export const useFrequentlyPlayedArtists = () => {
const api = useApi()
const adapter = useAdapter()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const [server] = useJellifyServer()
const { data: frequentlyPlayedTracks } = useFrequentlyPlayedTracks()
// Only run for Jellyfin backend
const isJellyfin = server?.backend !== 'navidrome'
return useInfiniteQuery({
queryKey: FrequentlyPlayedArtistsQueryKey(user, library),
queryFn: ({ pageParam }) => fetchFrequentlyPlayedArtists(api, user, library, pageParam),
queryFn: async ({ pageParam }) => {
// Use adapter for both backends (if method exists)
if (adapter?.getFrequentArtists) {
const artists = await adapter.getFrequentArtists(ApiLimits.Home * (pageParam + 1))
// Paginate the results client-side
const startIndex = pageParam * ApiLimits.Home
const paginatedArtists = artists.slice(startIndex, startIndex + ApiLimits.Home)
return unifiedArtistsToBaseItems(paginatedArtists)
}
// Fallback to Jellyfin-specific fetch
return fetchFrequentlyPlayedArtists(api, user, library, pageParam)
},
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length > 0 ? lastPageParam + 1 : undefined
},
enabled: isJellyfin && !isUndefined(frequentlyPlayedTracks),
enabled: !!adapter?.getFrequentArtists && !isUndefined(frequentlyPlayedTracks),
...FREQUENTS_QUERY_CONFIG,
})
}

View File

@@ -13,9 +13,66 @@ import { JellifyLibrary } from '../../types/JellifyLibrary'
import QueryConfig from '../../configs/query.config'
import { JellifyUser } from '../../types/JellifyUser'
import { nitroFetch } from '../utils/nitro'
import { MusicServerAdapter } from '../core/adapter'
import {
unifiedAlbumToBaseItem,
unifiedArtistToBaseItem,
unifiedTrackToBaseItem,
} from '../../utils/unified-conversions'
/**
* Fetches a single Jellyfin item by it's ID
* Fetches a single item by its ID using the adapter pattern.
* Works for both Jellyfin and Navidrome.
* @param adapter The music server adapter (or undefined for Jellyfin fallback)
* @param api The Jellyfin API (for Jellyfin fallback)
* @param itemId The ID of the item to fetch
* @param itemType Optional hint about the item type (album, artist, track)
* @returns The item - a {@link BaseItemDto}
*/
export async function fetchItemWithAdapter(
adapter: MusicServerAdapter | undefined,
api: Api | undefined,
itemId: string,
itemType?: 'album' | 'artist' | 'track',
): Promise<BaseItemDto> {
if (isEmpty(itemId)) throw new Error('No item ID provided')
// Use adapter for Navidrome
if (adapter?.backend === 'navidrome') {
// Try to fetch based on type hint, or try each type
if (itemType === 'album' || !itemType) {
try {
const album = await adapter.getAlbum(itemId)
return unifiedAlbumToBaseItem(album)
} catch {
if (itemType === 'album') throw new Error(`Album not found: ${itemId}`)
}
}
if (itemType === 'artist' || !itemType) {
try {
const artist = await adapter.getArtist(itemId)
return unifiedArtistToBaseItem(artist)
} catch {
if (itemType === 'artist') throw new Error(`Artist not found: ${itemId}`)
}
}
if (itemType === 'track' || !itemType) {
try {
const track = await adapter.getTrack(itemId)
return unifiedTrackToBaseItem(track)
} catch {
if (itemType === 'track') throw new Error(`Track not found: ${itemId}`)
}
}
throw new Error(`Item not found: ${itemId}`)
}
// Fallback to Jellyfin API
return fetchItem(api, itemId)
}
/**
* Fetches a single Jellyfin item by it's ID (Jellyfin-only, use fetchItemWithAdapter for adapter support)
* @param itemId The ID of the item to fetch
* @returns The item - a {@link BaseItemDto}
*/
@@ -90,6 +147,37 @@ export async function fetchItems(
})
}
/**
* Fetches tracks for an album, sectioned into discs for display in a {@link SectionList}
* Supports both Jellyfin and Navidrome via adapter.
* @param adapter The music server adapter
* @param api The Jellyfin API (for fallback)
* @param album The album to fetch tracks for
* @returns An array of {@link Section}s, where each section title is the disc number,
* and the data is the disc tracks - an array of {@link BaseItemDto}s
*/
export async function fetchAlbumDiscsWithAdapter(
adapter: MusicServerAdapter | undefined,
api: Api | undefined,
album: BaseItemDto,
): Promise<{ title: string; data: BaseItemDto[] }[]> {
if (isEmpty(album.Id)) throw new Error('No album ID provided')
// Use adapter for Navidrome
if (adapter?.backend === 'navidrome') {
const tracks = await adapter.getAlbumTracks(album.Id!)
// Group by disc number
const grouped = groupBy(tracks, (t) => t.discNumber ?? 1)
return Object.keys(grouped).map((discNumber) => ({
title: discNumber,
data: grouped[parseInt(discNumber)].map(unifiedTrackToBaseItem),
}))
}
// Fallback to Jellyfin API
return fetchAlbumDiscs(api, album)
}
/**
* Fetches tracks for an album, sectioned into discs for display in a {@link SectionList}
* @param album The album to fetch tracks for

View File

@@ -2,59 +2,83 @@ import { UserPlaylistsQueryKey } from './keys'
import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchUserPlaylists, fetchPublicPlaylists, fetchPlaylistTracks } from './utils'
import { ApiLimits } from '../../../configs/query.config'
import { useApi, useJellifyLibrary, useJellifyUser, useJellifyServer } from '../../../stores'
import {
useApi,
useJellifyLibrary,
useJellifyUser,
useAdapter,
useJellifyServer,
} from '../../../stores'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { QueryKeys } from '../../../enums/query-keys'
import {
unifiedPlaylistsToBaseItems,
unifiedTracksToBaseItems,
} from '../../../utils/unified-conversions'
export const useUserPlaylists = () => {
const api = useApi()
const adapter = useAdapter()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const [server] = useJellifyServer()
// Only run for Jellyfin backend - Navidrome uses the adapter-based hooks
const isJellyfin = server?.backend !== 'navidrome'
return useInfiniteQuery({
queryKey: UserPlaylistsQueryKey(library),
queryFn: () => fetchUserPlaylists(api, user, library),
queryFn: async () => {
// Use adapter for both backends
if (adapter) {
const playlists = await adapter.getPlaylists()
return unifiedPlaylistsToBaseItems(playlists)
}
// Fallback to Jellyfin-specific fetch
return fetchUserPlaylists(api, user, library)
},
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
if (!lastPage) return undefined
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
enabled: isJellyfin,
enabled: !!adapter,
})
}
export const usePlaylistTracks = (playlist: BaseItemDto) => {
const api = useApi()
const [server] = useJellifyServer()
// Only run for Jellyfin backend
const isJellyfin = server?.backend !== 'navidrome'
const adapter = useAdapter()
return useInfiniteQuery({
// Changed from QueryKeys.ItemTracks to avoid cache conflicts with old useQuery data
queryKey: [QueryKeys.ItemTracks, 'infinite', playlist.Id!],
queryFn: ({ pageParam }) => fetchPlaylistTracks(api, playlist.Id!, pageParam),
queryFn: async ({ pageParam }) => {
// Use adapter for both backends
if (adapter && playlist.Id) {
const tracks = await adapter.getPlaylistTracks(playlist.Id)
// Paginate client-side
const startIndex = pageParam * ApiLimits.Library
const paginatedTracks = tracks.slice(startIndex, startIndex + ApiLimits.Library)
return unifiedTracksToBaseItems(paginatedTracks)
}
// Fallback to Jellyfin-specific fetch
return fetchPlaylistTracks(api, playlist.Id!, pageParam)
},
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (!lastPage) return undefined
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
enabled: isJellyfin && Boolean(api && playlist.Id),
enabled: !!adapter && Boolean(playlist.Id),
})
}
// Note: Public playlists is a Jellyfin-specific concept
export const usePublicPlaylists = () => {
const api = useApi()
const [library] = useJellifyLibrary()
const [server] = useJellifyServer()
// Only run for Jellyfin backend
// Only run for Jellyfin backend - Navidrome doesn't have public playlists
const isJellyfin = server?.backend !== 'navidrome'
return useInfiniteQuery({

View File

@@ -3,7 +3,11 @@ import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { isUndefined } from 'lodash'
import { useApi, useJellifyUser, useJellifyLibrary, useJellifyServer } from '../../../stores'
import { useApi, useJellifyUser, useJellifyLibrary, useAdapter } from '../../../stores'
import {
unifiedTracksToBaseItems,
unifiedArtistsToBaseItems,
} from '../../../utils/unified-conversions'
const RECENTS_QUERY_CONFIG = {
maxPages: MaxPages.Home,
@@ -12,46 +16,62 @@ const RECENTS_QUERY_CONFIG = {
export const useRecentlyPlayedTracks = () => {
const api = useApi()
const adapter = useAdapter()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const [server] = useJellifyServer()
// Only run for Jellyfin backend - Navidrome uses different queries
const isJellyfin = server?.backend !== 'navidrome'
return useInfiniteQuery({
queryKey: RecentlyPlayedTracksQueryKey(user, library),
queryFn: ({ pageParam }) => fetchRecentlyPlayed(api, user, library, pageParam),
queryFn: async ({ pageParam }) => {
// Use adapter for both backends (if method exists)
if (adapter?.getRecentTracks) {
const tracks = await adapter.getRecentTracks(ApiLimits.Home * (pageParam + 1))
// Paginate the results client-side
const startIndex = pageParam * ApiLimits.Home
const paginatedTracks = tracks.slice(startIndex, startIndex + ApiLimits.Home)
return unifiedTracksToBaseItems(paginatedTracks)
}
// Fallback to Jellyfin-specific fetch
return fetchRecentlyPlayed(api, user, library, pageParam)
},
initialPageParam: 0,
select: (data) => data.pages.flatMap((page) => page),
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length === ApiLimits.Home ? lastPageParam + 1 : undefined
},
enabled: isJellyfin,
enabled: !!adapter?.getRecentTracks,
...RECENTS_QUERY_CONFIG,
})
}
export const useRecentArtists = () => {
const api = useApi()
const adapter = useAdapter()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const [server] = useJellifyServer()
const { data: recentlyPlayedTracks } = useRecentlyPlayedTracks()
// Only run for Jellyfin backend
const isJellyfin = server?.backend !== 'navidrome'
return useInfiniteQuery({
queryKey: RecentlyPlayedArtistsQueryKey(user, library),
queryFn: ({ pageParam }) => fetchRecentlyPlayedArtists(api, user, library, pageParam),
queryFn: async ({ pageParam }) => {
// Use adapter for both backends (if method exists)
if (adapter?.getRecentArtists) {
const artists = await adapter.getRecentArtists(ApiLimits.Home * (pageParam + 1))
// Paginate the results client-side
const startIndex = pageParam * ApiLimits.Home
const paginatedArtists = artists.slice(startIndex, startIndex + ApiLimits.Home)
return unifiedArtistsToBaseItems(paginatedArtists)
}
// Fallback to Jellyfin-specific fetch
return fetchRecentlyPlayedArtists(api, user, library, pageParam)
},
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.length > 0 ? lastPageParam + 1 : undefined
},
enabled: isJellyfin && !isUndefined(recentlyPlayedTracks),
enabled: !!adapter?.getRecentArtists && !isUndefined(recentlyPlayedTracks),
...RECENTS_QUERY_CONFIG,
})
}

View File

@@ -1,28 +1,47 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { SuggestionQueryKeys } from './keys'
import { fetchArtistSuggestions, fetchSearchSuggestions } from './utils/suggestions'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import { useApi, useJellifyLibrary, useJellifyUser, useAdapter } from '../../../stores'
import { isUndefined } from 'lodash'
import {
unifiedArtistsToBaseItems,
unifiedTracksToBaseItems,
unifiedAlbumsToBaseItems,
} from '../../../utils/unified-conversions'
export const useSearchSuggestions = () => {
const api = useApi()
const adapter = useAdapter()
const [library] = useJellifyLibrary()
const [user] = useJellifyUser()
return useQuery({
queryKey: [SuggestionQueryKeys.SearchSuggestions, library?.musicLibraryId],
queryFn: () => fetchSearchSuggestions(api, user, library?.musicLibraryId),
enabled: !isUndefined(library),
queryKey: [
SuggestionQueryKeys.SearchSuggestions,
library?.musicLibraryId,
adapter?.backend,
],
queryFn: async () => {
// Use adapter for Navidrome (returns unified types, convert to BaseItemDto)
if (adapter?.backend === 'navidrome' && adapter.getSearchSuggestions) {
const suggestions = await adapter.getSearchSuggestions(10)
return [
...unifiedArtistsToBaseItems(suggestions.artists),
...unifiedAlbumsToBaseItems(suggestions.albums),
...unifiedTracksToBaseItems(suggestions.tracks),
]
}
// Use Jellyfin-specific fetch for Jellyfin
return fetchSearchSuggestions(api, user, library?.musicLibraryId)
},
enabled: !isUndefined(library) && !!adapter,
})
}
export const useDiscoverArtists = () => {
const api = useApi()
const adapter = useAdapter()
const [library] = useJellifyLibrary()
const [user] = useJellifyUser()
return useInfiniteQuery({
@@ -31,8 +50,18 @@ export const useDiscoverArtists = () => {
user?.id,
library?.musicLibraryId,
],
queryFn: ({ pageParam }) =>
fetchArtistSuggestions(api, user, library?.musicLibraryId, pageParam),
queryFn: async ({ pageParam }) => {
// Use adapter for Navidrome
if (adapter?.backend === 'navidrome') {
// Navidrome getArtists returns all artists, slice for pagination
const allArtists = await adapter.getArtists()
const startIndex = pageParam * 50
const paginatedArtists = allArtists.slice(startIndex, startIndex + 50)
return unifiedArtistsToBaseItems(paginatedArtists)
}
// Use Jellyfin-specific fetch for Jellyfin
return fetchArtistSuggestions(api, user, library?.musicLibraryId, pageParam)
},
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.length > 0 ? lastPageParam + 1 : undefined,
select: (data) => data.pages.flatMap((page) => page),

View File

@@ -2,16 +2,29 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item
import { useQuery } from '@tanstack/react-query'
import fetchUserData from './utils'
import UserDataQueryKey from './keys'
import { useApi, useJellifyUser } from '../../../stores'
import { useApi, useJellifyUser, useAdapter } from '../../../stores'
export const useIsFavorite = (item: BaseItemDto) => {
const api = useApi()
const adapter = useAdapter()
const [user] = useJellifyUser()
return useQuery({
queryKey: UserDataQueryKey(user!, item),
queryFn: () => fetchUserData(api, user, item.Id!),
select: (data) => typeof data === 'object' && data.IsFavorite,
enabled: !!api && !!user && !!item.Id, // Only run if we have the required data
queryKey: [...UserDataQueryKey(user!, item), adapter?.backend],
queryFn: async () => {
// For Navidrome, check if item is in the starred list
if (adapter?.backend === 'navidrome') {
const starred = await adapter.getStarred()
const isStarred =
starred.artists.some((a) => a.id === item.Id) ||
starred.albums.some((a) => a.id === item.Id) ||
starred.tracks.some((t) => t.id === item.Id)
return { IsFavorite: isStarred }
}
// For Jellyfin, use the existing user data fetch
return fetchUserData(api, user, item.Id!)
},
select: (data) => typeof data === 'object' && data?.IsFavorite,
enabled: !!adapter && !!item.Id,
})
}

View File

@@ -19,6 +19,7 @@ import {
FrequentlyPlayedArtistsQueryKey,
FrequentlyPlayedTracksQueryKey,
} from '../../api/queries/frequents/keys'
import { MusicServerAdapter } from '../../api/core/adapter'
const CarPlayHome = (
library: JellifyLibrary,
@@ -27,6 +28,7 @@ const CarPlayHome = (
user: JellifyUser | undefined,
networkStatus: networkStatusTypes | null,
deviceProfile: DeviceProfile | undefined,
adapter: MusicServerAdapter | undefined,
) =>
new ListTemplate({
id: uuid.v4(),
@@ -73,6 +75,7 @@ const CarPlayHome = (
api,
networkStatus,
deviceProfile,
adapter,
),
)
break
@@ -100,6 +103,7 @@ const CarPlayHome = (
api,
networkStatus,
deviceProfile,
adapter,
),
)
break

View File

@@ -8,6 +8,7 @@ import { Api } from '@jellyfin/sdk'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client'
import { JellifyUser } from '@/src/types/JellifyUser'
import { MusicServerAdapter } from '../../api/core/adapter'
const CarPlayNavigation = (
library: JellifyLibrary,
@@ -16,12 +17,13 @@ const CarPlayNavigation = (
user: JellifyUser | undefined,
networkStatus: networkStatusTypes | null,
deviceProfile: DeviceProfile | undefined,
adapter: MusicServerAdapter | undefined,
) =>
new TabBarTemplate({
id: uuid.v4(),
title: 'Tabs',
templates: [
CarPlayHome(library, loadQueue, api, user, networkStatus, deviceProfile),
CarPlayHome(library, loadQueue, api, user, networkStatus, deviceProfile, adapter),
CarPlayDiscover,
],
onTemplateSelect(template, e) {},

View File

@@ -7,6 +7,7 @@ import { QueueMutation } from '../../providers/Player/interfaces'
import { QueuingType } from '../../enums/queuing-type'
import { Api } from '@jellyfin/sdk'
import { networkStatusTypes } from '../Network/internetConnectionWatcher'
import { MusicServerAdapter } from '../../api/core/adapter'
const TracksTemplate = (
items: BaseItemDto[],
@@ -15,6 +16,7 @@ const TracksTemplate = (
api: Api | undefined,
networkStatus: networkStatusTypes | null,
deviceProfile: DeviceProfile | undefined,
adapter: MusicServerAdapter | undefined,
) =>
new ListTemplate({
id: uuid.v4(),
@@ -32,6 +34,7 @@ const TracksTemplate = (
onItemSelect: async ({ index }) => {
loadQueue({
api,
adapter,
networkStatus,
deviceProfile,
queuingType: QueuingType.FromSelection,

View File

@@ -11,7 +11,6 @@ import Icon from '../Global/components/icon'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { AddToQueueMutation } from '../../providers/Player/interfaces'
import { QueuingType } from '../../enums/queuing-type'
import { useEffect } from 'react'
@@ -32,7 +31,7 @@ import { Platform } from 'react-native'
import { useApi, useJellifyServer } from '../../stores'
import useAddToPendingDownloads, { useIsDownloading } from '../../stores/network/downloads'
import DeletePlaylistRow from './components/delete-playlist-row'
import { useAlbumDiscs, useAlbum, useArtist } from '../../hooks/adapter'
import { useAlbumDiscs, useAlbum, useArtist, usePlaylistTracks } from '../../hooks/adapter'
import {
unifiedTrackToDto,
unifiedAlbumToDto,
@@ -58,7 +57,6 @@ export default function ItemContext({
}: ContextProps): React.JSX.Element {
const api = useApi()
const [server] = useJellifyServer()
const isNavidrome = server?.backend === 'navidrome'
const trigger = useHapticFeedback()
@@ -72,18 +70,9 @@ export default function ItemContext({
const { data: unifiedAlbum } = useAlbum(albumId)
const album = unifiedAlbum ? unifiedAlbumToDto(unifiedAlbum) : undefined
// Use Jellyfin SDK for playlists (no unified hook yet)
const { data: tracks } = useQuery({
queryKey: [QueryKeys.ItemTracks, item.Id],
queryFn: () =>
getItemsApi(api!)
.getItems({ parentId: item.Id! })
.then(({ data }) => {
if (data.Items) return data.Items
else return []
}),
enabled: isPlaylist && !isNavidrome,
})
// Use unified hook for playlist tracks - works for both backends
const { data: unifiedPlaylistTracks } = usePlaylistTracks(isPlaylist ? item.Id : undefined)
const tracks = unifiedPlaylistTracks?.map(unifiedTrackToDto)
// Use unified hook for album discs
const { data: unifiedDiscs } = useAlbumDiscs(isAlbum ? item.Id : undefined)

View File

@@ -1,8 +1,8 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import React, { useCallback } from 'react'
import React from 'react'
import Icon from './icon'
import Animated, { BounceIn, FadeIn, FadeOut } from 'react-native-reanimated'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { useStar, useUnstar } from '../../../hooks/adapter/useFavorites'
import { useIsFavorite } from '../../../api/queries/user-data'
import { getTokenValue, Spinner } from 'tamagui'
@@ -17,14 +17,14 @@ export default function FavoriteButton({ item, onToggle }: FavoriteButtonProps):
return isPending ? (
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
) : isFavorite ? (
<AddFavoriteButton item={item} onToggle={onToggle} />
<RemoveFromFavorites item={item} onToggle={onToggle} />
) : (
<RemoveFavoriteButton item={item} onToggle={onToggle} />
<AddToFavorites item={item} onToggle={onToggle} />
)
}
function AddFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
const { mutate, isPending } = useRemoveFavorite()
function RemoveFromFavorites({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
const { mutate, isPending } = useUnstar()
return isPending ? (
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
@@ -33,19 +33,19 @@ function AddFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.E
<Icon
name={'heart'}
color={'$primary'}
onPress={() =>
mutate({
item,
onToggle,
})
}
onPress={() => {
if (item.Id) {
mutate(item.Id)
onToggle?.()
}
}}
/>
</Animated.View>
)
}
function RemoveFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
const { mutate, isPending } = useAddFavorite()
function AddToFavorites({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
const { mutate, isPending } = useStar()
return isPending ? (
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
@@ -54,12 +54,12 @@ function RemoveFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JS
<Icon
name={'heart-outline'}
color={'$primary'}
onPress={() =>
mutate({
item,
onToggle,
})
}
onPress={() => {
if (item.Id) {
mutate(item.Id)
onToggle?.()
}
}}
/>
</Animated.View>
)

View File

@@ -0,0 +1,76 @@
/**
* Recently Added Albums section for Home screen.
* Works for both Jellyfin and Navidrome using the unified adapter pattern.
*/
import React from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { H5, XStack } from 'tamagui'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'
import { useNewestAlbums } from '../../../hooks/adapter'
import { ItemCard } from '../../Global/components/item-card'
import HorizontalCardList from '../../Global/components/horizontal-list'
import Icon from '../../Global/components/icon'
import { useDisplayContext } from '../../../providers/Display/display-provider'
import HomeStackParamList from '../../../screens/Home/types'
import { RootStackParamList } from '../../../screens/types'
import { unifiedAlbumsToBaseItems } from '../../../utils/unified-conversions'
export default function RecentlyAddedAlbums(): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>()
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const { horizontalItems } = useDisplayContext()
const { data: newestAlbums } = useNewestAlbums(15)
// Don't render if no data (Jellyfin may not support this query)
if (!newestAlbums?.length) return <></>
// Convert to BaseItemDto for compatibility with existing components
const baseItems = unifiedAlbumsToBaseItems(newestAlbums)
return (
<Animated.View
entering={FadeIn.springify()}
exiting={FadeOut.springify()}
layout={LinearTransition.springify()}
style={{ flex: 1 }}
>
<XStack alignItems='center'>
<H5 marginLeft='$2'>Recently Added</H5>
<Icon name='arrow-right' />
</XStack>
<HorizontalCardList
data={
baseItems.length > horizontalItems
? baseItems.slice(0, horizontalItems)
: baseItems
}
renderItem={({ index, item: album }) => (
<ItemCard
size='$11'
caption={album.Name}
subCaption={album.AlbumArtist}
squared
testId={`recently-added-album-${index}`}
item={album}
onPress={() => {
navigation.navigate('Album', { album })
}}
onLongPress={() => {
rootNavigation.navigate('Context', {
item: album,
navigation,
})
}}
marginHorizontal='$1'
captionAlign='left'
/>
)}
/>
</Animated.View>
)
}

View File

@@ -4,44 +4,34 @@ import RecentArtists from './helpers/recent-artists'
import RecentlyPlayed from './helpers/recently-played'
import FrequentArtists from './helpers/frequent-artists'
import FrequentlyPlayedTracks from './helpers/frequent-tracks'
import NavidromeHomeContent from './helpers/navidrome-content'
import RecentlyAddedAlbums from './helpers/recently-added-albums'
import { usePreventRemove } from '@react-navigation/native'
import useHomeQueries from '../../api/mutations/home'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
import { useIsRestoring } from '@tanstack/react-query'
import { useRecentlyPlayedTracks } from '../../api/queries/recents'
import { useJellifyServer } from '../../stores'
import { useNavidromeHomeContent } from '../../hooks/adapter'
import { useAdapter } from '../../stores'
const COMPONENT_NAME = 'Home'
export function Home(): React.JSX.Element {
const theme = useTheme()
const [server] = useJellifyServer()
const isNavidrome = server?.backend === 'navidrome'
const adapter = useAdapter()
usePreventRemove(true, () => {})
usePerformanceMonitor(COMPONENT_NAME, 5)
// Use different refresh logic based on backend
const jellyfinHomeQueries = useHomeQueries()
const navidromeHomeContent = useNavidromeHomeContent()
const { isPending: loadingJellyfinData } = useRecentlyPlayedTracks()
// Unified refresh logic using homeQueries
const homeQueries = useHomeQueries()
const { isPending: loadingData } = useRecentlyPlayedTracks()
const isRestoring = useIsRestoring()
const refreshing = isNavidrome
? navidromeHomeContent.isPending
: jellyfinHomeQueries.isPending || loadingJellyfinData
const refreshing = homeQueries.isPending || loadingData
const handleRefresh = async () => {
if (isNavidrome) {
await navidromeHomeContent.refetchAll()
} else {
await jellyfinHomeQueries.mutateAsync()
}
await homeQueries.mutateAsync()
}
return (
@@ -58,25 +48,35 @@ export function Home(): React.JSX.Element {
/>
}
>
{isNavidrome ? <NavidromeHomeContent /> : <JellyfinHomeContent />}
<UnifiedHomeContent />
</ScrollView>
)
}
function JellyfinHomeContent(): React.JSX.Element {
/**
* Unified home content component.
* Renders all sections - each section conditionally shows based on data availability.
* For Jellyfin: artist/track sections will have data
* For Navidrome: album sections will have data
* Some may have data for both backends if the adapter supports it.
*/
function UnifiedHomeContent(): React.JSX.Element {
return (
<YStack
alignContent='flex-start'
gap='$3'
marginBottom={Platform.OS === 'android' ? '$4' : undefined}
>
{/* Artist sections - work for both backends via adapter */}
<RecentArtists />
<RecentlyPlayed />
<FrequentArtists />
{/* Track sections - work for both backends via adapter */}
<RecentlyPlayed />
<FrequentlyPlayedTracks />
{/* Album section - shows recently added albums (works for both) */}
<RecentlyAddedAlbums />
</YStack>
)
}

View File

@@ -4,7 +4,7 @@ import { TextTickerConfig } from '../component.config'
import { Text } from '../../Global/helpers/text'
import React, { useCallback, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchItem } from '../../../api/queries/item'
import { fetchItemWithAdapter } from '../../../api/queries/item'
import FavoriteButton from '../../Global/components/favorite-button'
import { QueryKeys } from '../../../enums/query-keys'
import navigationRef from '../../../../navigation'
@@ -18,7 +18,7 @@ import { runOnJS } from 'react-native-worklets'
import { usePrevious, useSkip } from '../../../providers/Player/hooks/mutations'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
import { useCurrentTrack } from '../../../stores/player/queue'
import { useApi } from '../../../stores'
import { useApi, useAdapter } from '../../../stores'
type SongInfoProps = {
// Shared animated value coming from Player to drive overlay icons
@@ -27,6 +27,7 @@ type SongInfoProps = {
export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Element {
const api = useApi()
const adapter = useAdapter()
const skip = useSkip()
const previous = usePrevious()
const trigger = useHapticFeedback()
@@ -72,9 +73,9 @@ export default function SongInfo({ swipeX }: SongInfoProps = {}): React.JSX.Elem
const nowPlaying = useCurrentTrack()
const { data: album } = useQuery({
queryKey: [QueryKeys.Album, nowPlaying!.item.AlbumId],
queryFn: () => fetchItem(api, nowPlaying!.item.AlbumId!),
enabled: !!nowPlaying?.item.AlbumId && !!api,
queryKey: [QueryKeys.Album, nowPlaying!.item.AlbumId, adapter?.backend],
queryFn: () => fetchItemWithAdapter(adapter, api, nowPlaying!.item.AlbumId!, 'album'),
enabled: !!nowPlaying?.item.AlbumId && !!adapter,
})
// Memoize expensive computations

View File

@@ -1,13 +1,9 @@
import React, { useState } from 'react'
import React, { useState, useMemo } from 'react'
import Input from '../Global/helpers/input'
import { Text } from '../Global/helpers/text'
import ItemRow from '../Global/components/item-row'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { QueryKeys } from '../../enums/query-keys'
import { fetchSearchResults } from '../../api/queries/search'
import { useQuery } from '@tanstack/react-query'
import { FlatList } from 'react-native'
import { fetchSearchSuggestions } from '../../api/queries/suggestions/utils/suggestions'
import { getToken, H3, Separator, Spinner, YStack } from 'tamagui'
import Suggestions from './suggestions'
import { isEmpty } from 'lodash'
@@ -15,50 +11,41 @@ import HorizontalCardList from '../Global/components/horizontal-list'
import { ItemCard } from '../Global/components/item-card'
import SearchParamList from '../../screens/Search/types'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../stores'
import { useSearch } from '../../hooks/adapter/useSearch'
import { useSearchSuggestions } from '../../api/queries/suggestions'
import {
unifiedArtistsToBaseItems,
unifiedAlbumsToBaseItems,
unifiedTracksToBaseItems,
} from '../../utils/unified-conversions'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
export default function Search({
navigation,
}: {
navigation: NativeStackNavigationProp<SearchParamList, 'SearchScreen'>
}): React.JSX.Element {
const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const [searchString, setSearchString] = useState<string>('')
const [searchString, setSearchString] = useState<string | undefined>(undefined)
const {
data: items,
refetch,
isFetching: fetchingResults,
} = useQuery({
queryKey: [QueryKeys.Search, library?.musicLibraryId, searchString],
queryFn: () => fetchSearchResults(api, user, library?.musicLibraryId, searchString),
// Use the adapter-based search hook (works with both Jellyfin and Navidrome)
const { data: searchResults, isFetching: fetchingResults } = useSearch(searchString, {
enabled: searchString.trim().length > 0,
})
const {
data: suggestions,
isFetching: fetchingSuggestions,
refetch: refetchSuggestions,
} = useSearchSuggestions()
// Convert unified search results to BaseItemDto for display
const items = useMemo<BaseItemDto[]>(() => {
if (!searchResults) return []
return [
...unifiedArtistsToBaseItems(searchResults.artists),
...unifiedAlbumsToBaseItems(searchResults.albums),
...unifiedTracksToBaseItems(searchResults.tracks),
]
}, [searchResults])
const search = () => {
let timeout: ReturnType<typeof setTimeout>
return () => {
clearTimeout(timeout)
timeout = setTimeout(() => {
refetch()
refetchSuggestions()
}, 1000)
}
}
const { data: suggestions, isFetching: fetchingSuggestions } = useSearchSuggestions()
const handleSearchStringUpdate = (value: string | undefined) => {
setSearchString(value)
search()
setSearchString(value ?? '')
}
const handleScrollBeginDrag = () => {

View File

@@ -4,33 +4,27 @@ import { Api } from '@jellyfin/sdk'
import { ONE_DAY, queryClient } from '../constants/query-client'
import { QueryKeys } from '../enums/query-keys'
import { fetchMediaInfo } from '../api/queries/media/utils'
import { fetchAlbumDiscs, fetchItem } from '../api/queries/item'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { fetchAlbumDiscsWithAdapter, fetchItemWithAdapter } from '../api/queries/item'
import fetchUserData from '../api/queries/user-data/utils'
import { useRef } from 'react'
import useStreamingDeviceProfile, { useDownloadingDeviceProfile } from '../stores/device-profile'
import UserDataQueryKey from '../api/queries/user-data/keys'
import MediaInfoQueryKey from '../api/queries/media/keys'
import { useApi, useJellifyUser, useJellifyServer } from '../stores'
import { useApi, useJellifyUser, useAdapter } from '../stores'
import { MusicServerAdapter } from '../api/core/adapter'
import { unifiedTracksToBaseItems } from '../utils/unified-conversions'
export default function useItemContext(): (item: BaseItemDto) => void {
const api = useApi()
const [user] = useJellifyUser()
const [server] = useJellifyServer()
// Skip Jellyfin-specific prefetching for Navidrome
const isNavidrome = server?.backend === 'navidrome'
const adapter = useAdapter()
const streamingDeviceProfile = useStreamingDeviceProfile()
const downloadingDeviceProfile = useDownloadingDeviceProfile()
const prefetchedContext = useRef<Set<string>>(new Set())
return (item: BaseItemDto) => {
// Skip all Jellyfin-specific prefetching for Navidrome
if (isNavidrome) return
const effectSig = `${item.Id}-${item.Type}`
// If we've already warmed the cache for this item, return
@@ -39,11 +33,12 @@ export default function useItemContext(): (item: BaseItemDto) => void {
// Mark this item's context as warmed, preventing reruns
prefetchedContext.current.add(effectSig)
warmItemContext(api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
warmItemContext(adapter, api, user, item, streamingDeviceProfile, downloadingDeviceProfile)
}
}
function warmItemContext(
adapter: MusicServerAdapter | undefined,
api: Api | undefined,
user: JellifyUser | undefined,
item: BaseItemDto,
@@ -56,59 +51,67 @@ function warmItemContext(
if (!Id) return
if (Type === BaseItemKind.Audio)
warmTrackContext(api, item, streamingDeviceProfile, downloadingDeviceProfile)
warmTrackContext(adapter, api, item, streamingDeviceProfile, downloadingDeviceProfile)
if (Type === BaseItemKind.MusicArtist)
queryClient.setQueryData([QueryKeys.ArtistById, Id], item)
if (Type === BaseItemKind.MusicAlbum) warmAlbumContext(api, item)
if (Type === BaseItemKind.MusicAlbum) warmAlbumContext(adapter, api, item)
/**
* Prefetch query for a playlist's tracks
*
* Referenced later in the context sheet
* Uses adapter for both backends
*/
if (Type === BaseItemKind.Playlist)
queryClient.ensureQueryData({
queryKey: [QueryKeys.ItemTracks, Id],
queryFn: () =>
getItemsApi(api!)
.getItems({ parentId: Id! })
.then(({ data }) => {
if (data.Items) return data.Items
else return []
}),
queryKey: [QueryKeys.ItemTracks, Id, adapter?.backend],
queryFn: async () => {
if (!adapter) return []
const tracks = await adapter.getPlaylistTracks(Id!)
return unifiedTracksToBaseItems(tracks)
},
})
if (queryClient.getQueryState(UserDataQueryKey(user!, item))?.status !== 'success') {
if (UserData) queryClient.setQueryData(UserDataQueryKey(user!, item), UserData)
else
queryClient.ensureQueryData({
queryKey: UserDataQueryKey(user!, item),
queryFn: () => fetchUserData(api, user, Id),
})
// User data prefetch - Jellyfin only (Navidrome uses starred list)
if (adapter?.backend !== 'navidrome') {
if (queryClient.getQueryState(UserDataQueryKey(user!, item))?.status !== 'success') {
if (UserData) queryClient.setQueryData(UserDataQueryKey(user!, item), UserData)
else
queryClient.ensureQueryData({
queryKey: UserDataQueryKey(user!, item),
queryFn: () => fetchUserData(api, user, Id),
})
}
}
}
function warmAlbumContext(api: Api | undefined, album: BaseItemDto): void {
function warmAlbumContext(
adapter: MusicServerAdapter | undefined,
api: Api | undefined,
album: BaseItemDto,
): void {
const { Id } = album
queryClient.setQueryData([QueryKeys.Album, Id], album)
const albumDiscsQueryKey = [QueryKeys.ItemTracks, Id]
const albumDiscsQueryKey = [QueryKeys.ItemTracks, Id, adapter?.backend]
if (queryClient.getQueryState(albumDiscsQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: albumDiscsQueryKey,
queryFn: () => fetchAlbumDiscs(api, album),
queryFn: () => fetchAlbumDiscsWithAdapter(adapter, api, album),
})
}
function warmArtistContext(api: Api | undefined, artistId: string): void {
function warmArtistContext(
adapter: MusicServerAdapter | undefined,
api: Api | undefined,
artistId: string,
): void {
// Fail fast if we don't have an artist ID to work with
if (!artistId) return
const queryKey = [QueryKeys.ArtistById, artistId]
const queryKey = [QueryKeys.ArtistById, artistId, adapter?.backend]
// Bail out if we have data
if (queryClient.getQueryState(queryKey)?.status === 'success') return
@@ -118,11 +121,12 @@ function warmArtistContext(api: Api | undefined, artistId: string): void {
*/
queryClient.ensureQueryData({
queryKey,
queryFn: () => fetchItem(api, artistId!),
queryFn: () => fetchItemWithAdapter(adapter, api, artistId!, 'artist'),
})
}
function warmTrackContext(
adapter: MusicServerAdapter | undefined,
api: Api | undefined,
track: BaseItemDto,
streamingDeviceProfile: DeviceProfile | undefined,
@@ -130,41 +134,45 @@ function warmTrackContext(
): void {
const { Id, AlbumId, ArtistItems } = track
if (
queryClient.getQueryState(
MediaInfoQueryKey({ api, deviceProfile: streamingDeviceProfile, itemId: Id! }),
)?.status !== 'success'
)
queryClient.ensureQueryData({
queryKey: MediaInfoQueryKey({
api,
deviceProfile: streamingDeviceProfile,
itemId: Id!,
}),
queryFn: () => fetchMediaInfo(api, streamingDeviceProfile, Id!),
staleTime: ONE_DAY,
// Media info prefetch - Jellyfin only (Navidrome doesn't have this concept)
if (adapter?.backend !== 'navidrome') {
if (
queryClient.getQueryState(
MediaInfoQueryKey({ api, deviceProfile: streamingDeviceProfile, itemId: Id! }),
)?.status !== 'success'
)
queryClient.ensureQueryData({
queryKey: MediaInfoQueryKey({
api,
deviceProfile: streamingDeviceProfile,
itemId: Id!,
}),
queryFn: () => fetchMediaInfo(api, streamingDeviceProfile, Id!),
staleTime: ONE_DAY,
})
const downloadedMediaSourceQueryKey = MediaInfoQueryKey({
api,
deviceProfile: downloadingDeviceProfile,
itemId: Id!,
})
const downloadedMediaSourceQueryKey = MediaInfoQueryKey({
api,
deviceProfile: downloadingDeviceProfile,
itemId: Id!,
})
if (queryClient.getQueryState(downloadedMediaSourceQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: downloadedMediaSourceQueryKey,
queryFn: () => fetchMediaInfo(api, downloadingDeviceProfile, track.Id),
staleTime: ONE_DAY,
})
}
if (queryClient.getQueryState(downloadedMediaSourceQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: downloadedMediaSourceQueryKey,
queryFn: () => fetchMediaInfo(api, downloadingDeviceProfile, track.Id),
staleTime: ONE_DAY,
})
const albumQueryKey = [QueryKeys.Album, AlbumId]
const albumQueryKey = [QueryKeys.Album, AlbumId, adapter?.backend]
if (AlbumId && queryClient.getQueryState(albumQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: albumQueryKey,
queryFn: () => fetchItem(api, AlbumId!),
queryFn: () => fetchItemWithAdapter(adapter, api, AlbumId!, 'album'),
})
if (ArtistItems) ArtistItems.forEach((artistItem) => warmArtistContext(api, artistItem.Id!))
if (ArtistItems)
ArtistItems.forEach((artistItem) => warmArtistContext(adapter, api, artistItem.Id!))
}

View File

@@ -5,7 +5,7 @@ import { CarPlay } from 'react-native-carplay'
import { useLoadNewQueue } from '../Player/hooks/mutations'
import { useNetworkStatus } from '../../stores/network'
import useStreamingDeviceProfile from '../../stores/device-profile'
import useJellifyStore, { useApi, useJellifyLibrary } from '../../stores'
import useJellifyStore, { useApi, useJellifyLibrary, useAdapter } from '../../stores'
interface CarPlayContext {
carplayConnected: boolean
@@ -13,6 +13,7 @@ interface CarPlayContext {
const CarPlayContextInitializer = () => {
const api = useApi()
const adapter = useAdapter()
const [library] = useJellifyLibrary()
const [carplayConnected, setCarPlayConnected] = useState(CarPlay ? CarPlay.connected : false)
@@ -35,6 +36,7 @@ const CarPlayContextInitializer = () => {
useJellifyStore.getState().user,
networkStatus,
deviceProfile,
adapter,
),
)

View File

@@ -15,9 +15,12 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { MusicServerAdapter } from '../../../api/core/adapter'
import { Api } from '@jellyfin/sdk'
import { DeviceProfile } from '@jellyfin/sdk/lib/generated-client/models'
import { StreamOptions, streamingQualityToStreamOptions } from '../../../api/core/types'
/**
* Map a BaseItemDto to JellifyTrack using either adapter (Navidrome) or existing mapper (Jellyfin).
* Jellyfin uses the original mapper which handles MediaInfo, transcoding, and downloads.
* Navidrome uses the adapter which generates Subsonic-style stream URLs.
*/
function mapTrackToJellify(
item: BaseItemDto,
@@ -25,9 +28,10 @@ function mapTrackToJellify(
api: Api | undefined,
deviceProfile: DeviceProfile | undefined,
queuingType: QueuingType,
streamOptions?: StreamOptions,
): JellifyTrack {
// If adapter is provided, use it (for Navidrome)
if (adapter) {
// Only use adapter for Navidrome - Jellyfin needs the full mapper for MediaInfo/transcoding/downloads
if (adapter?.backend === 'navidrome') {
// Convert BaseItemDto to UnifiedTrack-like shape for adapter
const unifiedTrack = {
id: item.Id ?? '',
@@ -42,10 +46,10 @@ function mapTrackToJellify(
coverArtId: item.AlbumId ?? item.Id, // Use album ID for cover art, fallback to track ID
normalizationGain: item.NormalizationGain ?? undefined,
}
return adapter.mapToJellifyTrack(unifiedTrack, queuingType)
return adapter.mapToJellifyTrack(unifiedTrack, queuingType, streamOptions)
}
// Fall back to existing Jellyfin mapper
// Jellyfin: use full mapper with MediaInfo, transcoding, and download support
return mapDtoToTrack(api!, item, deviceProfile!, queuingType)
}
@@ -63,6 +67,7 @@ export async function loadQueue({
adapter,
deviceProfile,
networkStatus = networkStatusTypes.ONLINE,
streamingQuality,
}: QueueMutation): Promise<LoadQueueResult> {
usePlayerQueueStore.getState().setQueueRef(queueRef)
usePlayerQueueStore.getState().setShuffled(shuffled)
@@ -80,9 +85,21 @@ export async function loadQueue({
downloadedTracks ?? [],
)
// Convert streaming quality setting to stream options for Navidrome
const streamOptions = streamingQuality
? streamingQualityToStreamOptions(streamingQuality)
: undefined
// Convert to JellifyTracks using adapter when available
let queue = availableAudioItems.map((item) =>
mapTrackToJellify(item, adapter, api, deviceProfile, QueuingType.FromSelection),
mapTrackToJellify(
item,
adapter,
api,
deviceProfile,
QueuingType.FromSelection,
streamOptions,
),
)
// Store the original unshuffled queue
@@ -125,9 +142,21 @@ export const playNextInQueue = async ({
adapter,
deviceProfile,
tracks,
streamingQuality,
}: AddToQueueMutation) => {
const streamOptions = streamingQuality
? streamingQualityToStreamOptions(streamingQuality)
: undefined
const tracksToPlayNext = tracks.map((item) =>
mapTrackToJellify(item, adapter, api, deviceProfile, QueuingType.PlayingNext),
mapTrackToJellify(
item,
adapter,
api,
deviceProfile,
QueuingType.PlayingNext,
streamOptions,
),
)
const currentIndex = await TrackPlayer.getActiveTrackIndex()
@@ -166,9 +195,21 @@ export const playLaterInQueue = async ({
adapter,
deviceProfile,
tracks,
streamingQuality,
}: AddToQueueMutation) => {
const streamOptions = streamingQuality
? streamingQualityToStreamOptions(streamingQuality)
: undefined
const newTracks = tracks.map((item) =>
mapTrackToJellify(item, adapter, api, deviceProfile, QueuingType.DirectlyQueued),
mapTrackToJellify(
item,
adapter,
api,
deviceProfile,
QueuingType.DirectlyQueued,
streamOptions,
),
)
// Then update RNTP

View File

@@ -60,6 +60,11 @@ export interface QueueMutation {
* Whether to start playback immediately.
*/
startPlayback?: boolean | undefined
/**
* The streaming quality setting (for Navidrome transcoding).
*/
streamingQuality?: 'original' | 'high' | 'medium' | 'low'
}
/**
@@ -93,6 +98,11 @@ export interface AddToQueueMutation {
* be it playing next, or playing in the queue later
*/
queuingType?: QueuingType | undefined
/**
* The streaming quality setting (for Navidrome transcoding).
*/
streamingQuality?: 'original' | 'high' | 'medium' | 'low'
}
/**

View File

@@ -4,7 +4,7 @@
*/
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { UnifiedAlbum, UnifiedArtist, UnifiedTrack } from '../api/core/types'
import { UnifiedAlbum, UnifiedArtist, UnifiedPlaylist, UnifiedTrack } from '../api/core/types'
/**
* Convert a UnifiedAlbum to a BaseItemDto for compatibility with existing components.
@@ -90,3 +90,27 @@ export function unifiedTrackToBaseItem(track: UnifiedTrack): BaseItemDto {
export function unifiedTracksToBaseItems(tracks: UnifiedTrack[]): BaseItemDto[] {
return tracks.map(unifiedTrackToBaseItem)
}
/**
* Convert a UnifiedPlaylist to a BaseItemDto for compatibility with existing components.
*/
export function unifiedPlaylistToBaseItem(playlist: UnifiedPlaylist): BaseItemDto {
return {
Id: playlist.id,
Name: playlist.name,
Type: BaseItemKind.Playlist,
ChildCount: playlist.trackCount,
RunTimeTicks: playlist.duration ? playlist.duration * 10_000_000 : undefined,
ImageTags: playlist.imageBlurHash ? { Primary: playlist.imageBlurHash } : undefined,
ImageBlurHashes: playlist.imageBlurHash
? { Primary: { [playlist.id]: playlist.imageBlurHash } }
: undefined,
}
}
/**
* Convert multiple UnifiedPlaylists to BaseItemDtos.
*/
export function unifiedPlaylistsToBaseItems(playlists: UnifiedPlaylist[]): BaseItemDto[] {
return playlists.map(unifiedPlaylistToBaseItem)
}