mirror of
https://github.com/Jellify-Music/App.git
synced 2025-12-21 10:39:59 -06:00
feat: add Navidrome backend support with new adapter and unified API integration
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
195
jest/functional/adapters/jellyfin-adapter.test.ts
Normal file
195
jest/functional/adapters/jellyfin-adapter.test.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
177
jest/functional/adapters/navidrome-adapter.test.ts
Normal file
177
jest/functional/adapters/navidrome-adapter.test.ts
Normal 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('!')
|
||||
})
|
||||
})
|
||||
})
|
||||
288
jest/functional/adapters/navidrome-mappings.test.ts
Normal file
288
jest/functional/adapters/navidrome-mappings.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
76
src/components/Home/helpers/recently-added-albums.tsx
Normal file
76
src/components/Home/helpers/recently-added-albums.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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!))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user