From 1567beee2e807418d5ad6c4899594911b63bae5b Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 21 Oct 2025 14:21:17 +0200 Subject: [PATCH] Refactoring profond de pmoparadise --- Cargo.lock | 16 + .../src/components/RadioParadiseExplorer.vue | 751 +++++++++-- pmoconfig/src/pmomusic.yaml | 36 +- pmoparadise/.github/workflows/ci.yml | 144 --- pmoparadise/.pmomusic_audio/cache.db | Bin 20480 -> 0 bytes pmoparadise/Cargo.toml | 13 +- pmoparadise/src/client.rs | 48 +- pmoparadise/src/lib.rs | 1 + pmoparadise/src/paradise/channel.rs | 395 ++++++ pmoparadise/src/paradise/config.rs | 317 +++++ pmoparadise/src/paradise/history.rs | 326 +++++ pmoparadise/src/paradise/mod.rs | 33 + pmoparadise/src/paradise/playlist.rs | 293 +++++ pmoparadise/src/paradise/worker.rs | 758 +++++++++++ pmoparadise/src/pmoserver_ext.rs | 484 +++++++- pmoparadise/src/source.rs | 1104 ++++------------- pmoserver/Cargo.toml | 2 +- pmosource/src/api.rs | 394 +++++- pmosource/src/cache.rs | 10 + 19 files changed, 3960 insertions(+), 1165 deletions(-) delete mode 100644 pmoparadise/.github/workflows/ci.yml delete mode 100644 pmoparadise/.pmomusic_audio/cache.db create mode 100644 pmoparadise/src/paradise/channel.rs create mode 100644 pmoparadise/src/paradise/config.rs create mode 100644 pmoparadise/src/paradise/history.rs create mode 100644 pmoparadise/src/paradise/mod.rs create mode 100644 pmoparadise/src/paradise/playlist.rs create mode 100644 pmoparadise/src/paradise/worker.rs diff --git a/Cargo.lock b/Cargo.lock index 52d7b073..c51c932a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2476,13 +2476,18 @@ name = "pmoparadise" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", + "async-trait", "axum", "bytes", + "chrono", "claxon", "flacenc", "futures", + "hex", "hound", "pmoaudiocache", + "pmoconfig", "pmocovers", "pmodidl", "pmoplaylist", @@ -2490,13 +2495,17 @@ dependencies = [ "pmosource", "pmoupnp", "reqwest", + "rusqlite", "serde", "serde_json", + "serde_yaml", + "sha2", "symphonia", "tempfile", "thiserror 1.0.69", "tokio", "tokio-test", + "tokio-util", "tracing", "tracing-subscriber", "url", @@ -4202,9 +4211,16 @@ dependencies = [ "serde_json", "url", "utoipa", + "utoipa-swagger-ui-vendored", "zip", ] +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "1.18.1" diff --git a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue index cd81a975..c52ed2cb 100644 --- a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue +++ b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue @@ -166,73 +166,221 @@ -
+
-

🎧 Channel Tracks (Source API)

-
+
+ ❌ {{ channelStatusError }} +
+
+
+
+
Channel
+
{{ channelStatus.slug }}
+
+
+
Active Clients
+
{{ channelStatus.active_clients }}
+
+
+
Queue Length
+
{{ channelStatus.queue_length }}
+
+
+
Update ID
+
{{ channelStatus.update_id }}
+
+
+
Last Change
+
+ {{ channelStatus.last_change ? formatTimestamp(new Date(channelStatus.last_change)) : '—' }} +
+
+
+
History Entries
+
+ {{ channelStatus.history_entries }} / {{ channelStatus.history_max_tracks }} +
+
+
+
Cache Collection
+
{{ channelStatus.cache_collection_id }}
+
+
+
Cache Tracks
+
+ {{ channelStatus.cache_cached_tracks }} / {{ channelStatus.cache_total_tracks }} +
+
+
+ +
+
+ +
+
+

🎧 Live Playlist

+
+ + +
+
-
- ❌ {{ channelBrowseError }} +
+ ❌ {{ channelPlaylistError }}
-
- ⏳ Loading channel tracks… +
+ ⏳ Loading playlist…
-
- {{ channelBrowse.containers.length }} sub container(s) available. -
-
+
- - {{ trackStatusLabel(item.__status) }} + + {{ cacheStatusLabel(item.cache_status) }} - +
{{ item.title }}
-
{{ item.artist || item.creator || 'Unknown artist' }}
+
{{ item.artist || 'Unknown artist' }}
{{ item.album }} - - ⏱ {{ item.resources[0].duration }} - + ⏱ {{ formatDuration(item.duration_ms) }} + ▶️ @{{ formatDuration(item.elapsed_ms) }} + 🕒 {{ formatTimestamp(new Date(item.started_at)) }} + 💾 {{ formatBytes(item.cache_status.size_bytes) }}
+ + + - Open resource + Open resolved URI
+
+ âś… {{ trackExtrasFor(item).cacheRequestMessage }} +
+
+ ❌ {{ trackExtrasFor(item).cacheError }} +
+
+ ❌ Resolve error: {{ trackExtrasFor(item).resolveError }} +
+
+ ❌ Formats error: {{ trackExtrasFor(item).formatsError }} +
+
+
+ {{ format.format_id }} + {{ format.mime_type }} + {{ format.sample_rate }} Hz + {{ format.bit_depth }} bit + {{ format.bitrate }} kbps + {{ format.channels }} ch +
+
- No cached tracks yet for this channel. Refresh after playback starts. + No tracks currently queued. Try refreshing after playback starts. +
+
+
+ +
+
+

🕰️ Recent History

+ +
+ +
+ ❌ {{ channelHistoryError }} +
+
+ ⏳ Loading history… +
+
+
+ No history entries yet. +
+
+
{{ entry.title }}
+
+ {{ entry.artist }} + • {{ entry.album }} + • {{ formatDuration(entry.duration_ms) }} + • {{ formatTimestamp(new Date(entry.started_at)) }} +
@@ -379,22 +527,31 @@ const blockSearchLoading = ref(false) const blockSearchError = ref('') const channelsError = ref('') const bitratesError = ref('') -const channelBrowse = ref({ - object_id: '', - containers: [], +const channelStatus = ref(null) +const channelStatusLoading = ref(false) +const channelStatusError = ref('') +const channelStatusLastUpdated = ref(null) + +const channelPlaylist = ref({ items: [], - returned_containers: 0, - returned_items: 0, - total: 0, - update_id: 0 + queue_length: 0, + update_id: 0, + slug: '', + channel_id: selectedChannel.value }) -const channelBrowseLoading = ref(false) -const channelBrowseError = ref('') -const channelBrowseLastUpdated = ref(null) +const channelPlaylistLoading = ref(false) +const channelPlaylistError = ref('') +const channelPlaylistLastUpdated = ref(null) + +const channelHistory = ref([]) +const channelHistoryLoading = ref(false) +const channelHistoryError = ref('') +const channelHistoryLastUpdated = ref(null) + +const trackExtras = ref({}) let refreshTimerId = null let channelRefreshTimerId = null -const CHANNEL_TRACKS_REFRESH_INTERVAL = 5000 -let channelTracksInFlight = false +const CHANNEL_REFRESH_INTERVAL = 7000 // Format duration from milliseconds to MM:SS function formatDuration(ms) { @@ -444,6 +601,10 @@ function channelObjectId(channelId) { return `${SOURCE_ID}:channel:${channelId}` } +function trackObjectId(item) { + return item?.track_id || item?.id || item?.object_id || '' +} + function playAudio(url) { if (!url) { audioError.value = 'No audio URL available' @@ -496,91 +657,311 @@ async function refreshNowPlaying() { } } -function deriveTrackStatus(item) { - const url = item?.resources?.[0]?.url || '' - if (!url) { - return 'pending' - } - if (url.includes('/audio/flac/') && !url.includes('#')) { - return 'cached' - } - if (url.includes('#')) { - return 'downloading' - } - return 'external' -} - -function trackStatusLabel(status) { +function cacheStatusLabel(cacheInfo) { + const status = cacheInfo?.status || 'not_cached' switch (status) { case 'cached': return 'Cached' - case 'downloading': - return 'Downloading' - case 'external': - return 'External' + case 'caching': + return cacheInfo?.progress != null + ? `Caching ${(cacheInfo.progress * 100).toFixed(0)}%` + : 'Caching' + case 'failed': + return 'Failed' + case 'not_cached': default: - return 'Pending' + return 'Not cached' } } -function trackStatusClass(status) { +function cacheStatusClass(cacheInfo) { + const status = cacheInfo?.status || 'not_cached' return { 'status-badge': true, cached: status === 'cached', - downloading: status === 'downloading', - external: status === 'external', - pending: status === 'pending' + caching: status === 'caching', + failed: status === 'failed', + pending: status === 'not_cached' } } -async function fetchChannelTracks(options = {}) { - const { silent = false } = options - if (channelTracksInFlight) { - return +function formatBytes(bytes) { + if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) { + return '0 B' } - channelTracksInFlight = true + const units = ['B', 'KB', 'MB', 'GB'] + let value = bytes + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}` +} + +async function fetchChannelStatus({ silent = false } = {}) { if (!silent) { - channelBrowseLoading.value = true + channelStatusLoading.value = true } - channelBrowseError.value = '' - + channelStatusError.value = '' try { - const params = new URLSearchParams() - params.set('object_id', channelObjectId(selectedChannel.value)) - params.set('requested_count', '0') - - const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/browse?${params.toString()}`) + const response = await fetch(`${API_BASE}/channels/${selectedChannel.value}/status`) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } - - const data = await response.json() - channelBrowse.value = { - object_id: data.object_id || channelObjectId(selectedChannel.value), - containers: data.containers ?? [], - items: data.items ?? [], - returned_containers: data.returned_containers ?? (data.containers?.length ?? 0), - returned_items: data.returned_items ?? (data.items?.length ?? 0), - total: data.total ?? ((data.containers?.length ?? 0) + (data.items?.length ?? 0)), - update_id: data.update_id ?? 0 - } - // annotate each item with status for UI - channelBrowse.value.items = channelBrowse.value.items.map((item) => ({ - ...item, - __status: deriveTrackStatus(item) - })) - channelBrowseLastUpdated.value = new Date() + channelStatus.value = await response.json() + channelStatusLastUpdated.value = new Date() } catch (e) { - channelBrowseError.value = `Failed to load channel tracks: ${e.message}` - console.error('Error fetching channel tracks:', e) + channelStatusError.value = `Failed to load channel status: ${e.message}` + console.error('Error fetching channel status:', e) } finally { - channelTracksInFlight = false if (!silent) { - channelBrowseLoading.value = false + channelStatusLoading.value = false } } } +async function fetchChannelPlaylist({ silent = false, limit = 24 } = {}) { + if (!silent) { + channelPlaylistLoading.value = true + } + channelPlaylistError.value = '' + try { + const params = new URLSearchParams() + if (limit != null) { + params.set('limit', String(limit)) + } + const response = await fetch( + `${API_BASE}/channels/${selectedChannel.value}/playlist?${params.toString()}` + ) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + const data = await response.json() + channelPlaylist.value = { + ...data, + items: (data.items || []).map((item) => ({ + ...item, + cache_status: item.cache_status || { status: 'not_cached', progress: 0 } + })) + } + channelPlaylistLastUpdated.value = new Date() + const validIds = new Set(channelPlaylist.value.items.map((item) => trackObjectId(item)).filter(Boolean)) + trackExtras.value = Object.fromEntries( + Object.entries(trackExtras.value).filter(([id]) => validIds.has(id)) + ) + } catch (e) { + channelPlaylistError.value = `Failed to load playlist: ${e.message}` + console.error('Error fetching channel playlist:', e) + } finally { + if (!silent) { + channelPlaylistLoading.value = false + } + } +} + +async function fetchChannelHistory({ silent = false, limit = 25 } = {}) { + if (!silent) { + channelHistoryLoading.value = true + } + channelHistoryError.value = '' + try { + const params = new URLSearchParams() + if (limit != null) { + params.set('limit', String(limit)) + } + const response = await fetch( + `${API_BASE}/channels/${selectedChannel.value}/history?${params.toString()}` + ) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + const data = await response.json() + channelHistory.value = data.entries || [] + channelHistoryLastUpdated.value = new Date() + } catch (e) { + channelHistoryError.value = `Failed to load history: ${e.message}` + console.error('Error fetching channel history:', e) + } finally { + if (!silent) { + channelHistoryLoading.value = false + } + } +} + +async function refreshChannelData({ silent = false } = {}) { + await Promise.all([ + fetchChannelStatus({ silent }), + fetchChannelPlaylist({ silent }), + fetchChannelHistory({ silent }) + ]) +} + +function updateTrackExtras(trackId, patch) { + if (!trackId) { + return + } + const current = trackExtras.value[trackId] || { + uri: '', + lastResolvedAt: null, + resolving: false, + resolveError: '', + formats: [], + formatsLoading: false, + formatsError: '', + cacheRequestLoading: false, + cacheRequestMessage: '', + cacheError: '', + cacheStatusLoading: false + } + trackExtras.value = { + ...trackExtras.value, + [trackId]: { + ...current, + ...patch + } + } +} + +function trackExtrasFor(item) { + const trackId = trackObjectId(item) + return trackExtras.value[trackId] || {} +} + +async function resolveTrackUri(trackId) { + if (!trackId) { + throw new Error('Missing track identifier') + } + updateTrackExtras(trackId, { resolving: true, resolveError: '' }) + try { + const params = new URLSearchParams() + params.set('object_id', trackId) + const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/resolve?${params.toString()}`) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + const data = await response.json() + updateTrackExtras(trackId, { + resolving: false, + uri: data.uri, + lastResolvedAt: new Date() + }) + return data.uri + } catch (e) { + updateTrackExtras(trackId, { resolving: false, resolveError: e.message }) + throw e + } +} + +async function refreshTrackCacheStatus(item) { + const trackId = trackObjectId(item) + if (!trackId) { + return + } + updateTrackExtras(trackId, { cacheStatusLoading: true, cacheError: '' }) + try { + const params = new URLSearchParams() + params.set('object_id', trackId) + const response = await fetch( + `${SOURCE_API_BASE}/${SOURCE_ID}/cache/status?${params.toString()}` + ) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + const data = await response.json() + const status = data.status || { status: 'not_cached' } + channelPlaylist.value = { + ...channelPlaylist.value, + items: channelPlaylist.value.items.map((entry) => + trackObjectId(entry) === trackId ? { ...entry, cache_status: status } : entry + ) + } + updateTrackExtras(trackId, { cacheStatusLoading: false, cacheError: '', cacheStatus: status }) + } catch (e) { + updateTrackExtras(trackId, { cacheStatusLoading: false, cacheError: e.message }) + console.error('Error refreshing cache status:', e) + } +} + +async function requestCacheForTrack(item) { + const trackId = trackObjectId(item) + if (!trackId) { + return + } + updateTrackExtras(trackId, { + cacheRequestLoading: true, + cacheRequestMessage: '', + cacheError: '' + }) + try { + const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/cache`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ object_id: trackId }) + }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + const message = body?.error || response.statusText + throw new Error(message) + } + const data = await response.json() + const status = data.status || data?.cache_status + if (status) { + channelPlaylist.value = { + ...channelPlaylist.value, + items: channelPlaylist.value.items.map((entry) => + trackObjectId(entry) === trackId ? { ...entry, cache_status: status } : entry + ) + } + } + updateTrackExtras(trackId, { + cacheRequestLoading: false, + cacheRequestMessage: 'Cache request accepted' + }) + } catch (e) { + updateTrackExtras(trackId, { + cacheRequestLoading: false, + cacheRequestMessage: '', + cacheError: e.message + }) + console.error('Error requesting cache:', e) + } finally { + await refreshTrackCacheStatus(item) + } +} + +async function fetchTrackFormats(item) { + const trackId = trackObjectId(item) + if (!trackId) { + return + } + const extras = trackExtrasFor(item) + if (extras.formats?.length && !extras.formatsError) { + return + } + updateTrackExtras(trackId, { formatsLoading: true, formatsError: '' }) + try { + const params = new URLSearchParams() + params.set('object_id', trackId) + const response = await fetch( + `${SOURCE_API_BASE}/${SOURCE_ID}/formats?${params.toString()}` + ) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + const data = await response.json() + updateTrackExtras(trackId, { + formatsLoading: false, + formats: data.formats || [] + }) + } catch (e) { + updateTrackExtras(trackId, { formatsLoading: false, formatsError: e.message }) + console.error('Error fetching track formats:', e) + } +} + // Fetch available channels async function fetchChannels() { try { @@ -635,15 +1016,17 @@ function selectChannel(channelId) { } async function changeChannel() { + trackExtras.value = {} await refreshNowPlaying() - await fetchChannelTracks() + await refreshChannelData() blockSearchResult.value = null blockSearchError.value = '' } async function changeBitrate() { + trackExtras.value = {} await refreshNowPlaying() - await fetchChannelTracks() + await refreshChannelData() blockSearchResult.value = null blockSearchError.value = '' } @@ -744,16 +1127,38 @@ function clearBlockSearch() { blockSearchError.value = '' } -function playTrackItem(item) { - const resource = item?.resources?.find(res => res.url) - if (!resource) { - audioError.value = 'No audio resource available for this track' +async function playTrackItem(item) { + const trackId = trackObjectId(item) + if (!trackId) { + audioError.value = 'Unable to determine track identifier' isPlaying.value = false return } - activeTrackId.value = item.id - playAudio(resource.url) + try { + let uri = null + try { + uri = await resolveTrackUri(trackId) + } catch (resolveError) { + console.warn('Falling back to direct resource due to resolve error:', resolveError) + } + + if (!uri) { + const fallback = item?.resources?.find((res) => res.url)?.url + uri = fallback + } + + if (!uri) { + throw new Error('No audio resource available for this track') + } + + activeTrackId.value = trackId + playAudio(uri) + } catch (e) { + audioError.value = e.message + isPlaying.value = false + activeTrackId.value = null + } } // Initialize on mount @@ -761,7 +1166,7 @@ onMounted(async () => { await fetchChannels() await fetchBitrates() await refreshNowPlaying() - await fetchChannelTracks() + await refreshChannelData() // Auto-refresh every 30 seconds refreshTimerId = window.setInterval(() => { @@ -772,10 +1177,10 @@ onMounted(async () => { // Auto-refresh channel tracks every few seconds channelRefreshTimerId = window.setInterval(() => { - if (!channelBrowseLoading.value) { - fetchChannelTracks({ silent: true }) + if (!channelPlaylistLoading.value && !channelStatusLoading.value) { + refreshChannelData({ silent: true }) } - }, CHANNEL_TRACKS_REFRESH_INTERVAL) + }, CHANNEL_REFRESH_INTERVAL) }) onUnmounted(() => { @@ -1326,6 +1731,13 @@ onUnmounted(() => { color: #9aa0a6; } +.section-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + .loading-message { margin-top: 16px; color: #9aa0a6; @@ -1364,6 +1776,36 @@ onUnmounted(() => { transition: border-color 0.2s, box-shadow 0.2s; } +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.status-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.status-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9aa0a6; +} + +.status-value { + font-size: 1.05rem; + font-weight: 600; + color: #f5f5f5; +} + .track-card-header { display: flex; justify-content: space-between; @@ -1393,22 +1835,22 @@ onUnmounted(() => { color: #2ecc71; } -.status-badge.downloading { +.status-badge.caching { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.4); color: #ffc107; } -.status-badge.external { - background: rgba(0, 212, 255, 0.12); - border-color: rgba(0, 212, 255, 0.4); - color: #00d4ff; +.status-badge.failed { + background: rgba(255, 87, 34, 0.18); + border-color: rgba(255, 87, 34, 0.5); + color: #ff7043; } .status-badge.pending { - background: rgba(255, 87, 34, 0.15); - border-color: rgba(255, 87, 34, 0.4); - color: #ff6d3a; + background: rgba(0, 212, 255, 0.12); + border-color: rgba(0, 212, 255, 0.4); + color: #00d4ff; } .track-metadata { @@ -1452,6 +1894,63 @@ onUnmounted(() => { align-items: center; } +.inline-error { + margin-top: 6px; + font-size: 0.8rem; + color: #ff6b6b; +} + +.inline-success { + margin-top: 6px; + font-size: 0.8rem; + color: #2ecc71; +} + +.formats-list { + margin-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8rem; + color: #9aa0a6; +} + +.format-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.history-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 12px; +} + +.history-item { + padding: 12px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.history-title { + font-weight: 600; + color: #f5f5f5; +} + +.history-meta { + margin-top: 4px; + font-size: 0.8rem; + color: #9aa0a6; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + .audio-player-container { margin: 12px 0 24px; padding: 16px; diff --git a/pmoconfig/src/pmomusic.yaml b/pmoconfig/src/pmomusic.yaml index ee610894..0a9c8f17 100644 --- a/pmoconfig/src/pmomusic.yaml +++ b/pmoconfig/src/pmomusic.yaml @@ -11,8 +11,34 @@ host: enable_console: true min_level: "INFO" - mediarenderer: - mpd_renderer: - mediaserver: - qobuz: - udn: "uuid:28963b75-4c5f-4da7-b10e-ffafd" \ No newline at end of file +mediarenderer: + +mediaserver: + sources: + radio_paradise: + enabled: true + cache: + max_blocks_remembered: 5 + track_id_hash_bytes: 512 + history: + max_tracks: 100 + persistence_backend: sqlite + database_path: /var/lib/pmo/paradise_history.db + activity: + cooling_timeout_seconds: 180 + polling: + interval_high_buffer: 120 + interval_medium_buffer: 60 + interval_low_buffer: 20 + backoff_on_error: + initial: 20 + max: 300 + multiplier: 2.0 + stream: + metadata_format: icy + enable_gapless: true + buffer_size_bytes: 65536 + api: + base_url: https://api.radioparadise.com + timeout_seconds: 30 + user_agent: "PMO-RadioParadise/1.0" diff --git a/pmoparadise/.github/workflows/ci.yml b/pmoparadise/.github/workflows/ci.yml deleted file mode 100644 index 5995db46..00000000 --- a/pmoparadise/.github/workflows/ci.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: CI - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -jobs: - test: - name: Test - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - rust: [stable, beta] - steps: - - uses: actions/checkout@v3 - - - name: Install Rust - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} - - - name: Cache cargo registry - uses: actions/cache@v3 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v3 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v3 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Run tests (default features) - run: cargo test --verbose - - - name: Run tests (per-track feature) - run: cargo test --verbose --features per-track - - - name: Run tests (all features) - run: cargo test --verbose --all-features - - fmt: - name: Rustfmt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - name: Check formatting - run: cargo fmt --all -- --check - - clippy: - name: Clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Run clippy (default features) - run: cargo clippy --all-targets -- -D warnings - - - name: Run clippy (all features) - run: cargo clippy --all-targets --all-features -- -D warnings - - doc: - name: Documentation - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build documentation - run: cargo doc --no-deps --all-features - env: - RUSTDOCFLAGS: -D warnings - - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build (default features) - run: cargo build --verbose - - - name: Build (no default features) - run: cargo build --verbose --no-default-features - - - name: Build (per-track feature) - run: cargo build --verbose --features per-track - - - name: Build (all features) - run: cargo build --verbose --all-features - - - name: Build release - run: cargo build --release --verbose - - coverage: - name: Code Coverage - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install cargo-tarpaulin - run: cargo install cargo-tarpaulin - - - name: Generate coverage - run: cargo tarpaulin --verbose --all-features --workspace --timeout 120 --out Xml - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - files: ./cobertura.xml - fail_ci_if_error: false diff --git a/pmoparadise/.pmomusic_audio/cache.db b/pmoparadise/.pmomusic_audio/cache.db deleted file mode 100644 index 6bb75df69dceb4727044f982454cf1a9d6b66f71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 zcmeI#L2J}N6u|LGck4=F+u|kHCuN}vMHCUQrrS}LuG_ke(4IovM2t;0Z8CxNsCe=- z^zO&-Yk4#YEt@u7Kfv-INHRlS@@9TBmv{1E6q!&yC+UT6^_O*9>e;U+-L1c>-TkdLuebheJ}o*l1Q0*~0R#|0009ILKmdVd0`qpq ze$}(S??kg8oJYaj^Yb7|Jd^s<^URCWygptZOtj-_8IBM1sXP=dM`NjfA-l1k886Sm zK%A4oo_vW+R`&YeS{-{(WVRp9JWb*_oSG<^)qmIi{{$8vHRrK-*Z(0_$L{s4pDp8m zio<$>dVlREnd|J2^u1MW$h~%XF0MXx<#;lD?@Z3*P@nBJg0dt}r=gdpaTVMQSWI$B zK*cv4yZW7;$bo+Ad>py*y!oSrdO54+`-{-{fp2{8YnIHaTz4khf4%m|KI~bl+MMhv zE^7JWWSFEH-#sHQ>Skpv|F}GC-SW0w-e?FQfB*srAbqfW2LS;D5I_I{1Q0*~0R#|00D;vPp#ER|J!XXn UAb String { +/// Default Radio Paradise API base URL +pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api"; + +/// Default block base URL pattern +pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan/0"; + +/// Default image base URL +pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/"; + +/// Default timeout for metadata HTTP requests +pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; + +/// Default timeout for large block downloads/streams +pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 180; + +/// Default User-Agent +pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0"; + + +fn normalize_cover_base_url(base: &str) -> String { let mut normalized = base.trim().to_string(); if normalized.is_empty() { - return "https://img.radioparadise.com/".to_string(); + return DEFAULT_IMAGE_BASE.to_string(); } if normalized.starts_with("//") { @@ -38,29 +57,12 @@ fn resolve_cover_with_base(base: &str, cover_path: &str) -> Result { return Ok(Url::parse(&url)?); } - let base = normalize_base_url(base); + let base = normalize_cover_base_url(base); let base_url = Url::parse(&base)?; Ok(base_url.join(cover_path)?) } -/// Default Radio Paradise API base URL -pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api"; - -/// Default block base URL pattern -pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan/0"; - -/// Default image base URL -pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/"; - -/// Default timeout for metadata HTTP requests -pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; - -/// Default timeout for large block downloads/streams -pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 180; - -/// Default User-Agent -pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0"; /// Radio Paradise HTTP client /// @@ -116,7 +118,7 @@ impl RadioParadiseClient { client, api_base: DEFAULT_API_BASE.to_string(), block_base: DEFAULT_BLOCK_BASE.to_string(), - image_base: normalize_base_url(DEFAULT_IMAGE_BASE), + image_base: normalize_cover_base_url(DEFAULT_IMAGE_BASE), bitrate: Bitrate::default(), channel: 0, request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), @@ -223,7 +225,7 @@ impl RadioParadiseClient { // Set image_base if not provided if let Some(ref mut base) = block.image_base { - *base = normalize_base_url(base); + *base = normalize_cover_base_url(base); } else { block.image_base = Some(self.image_base.clone()); } @@ -439,7 +441,7 @@ impl ClientBuilder { } else { self.block_base.clone() }; - let image_base = normalize_base_url(&self.image_base); + let image_base = normalize_cover_base_url(&self.image_base); Ok(RadioParadiseClient { client, diff --git a/pmoparadise/src/lib.rs b/pmoparadise/src/lib.rs index 30333e4a..263d5107 100644 --- a/pmoparadise/src/lib.rs +++ b/pmoparadise/src/lib.rs @@ -239,6 +239,7 @@ pub mod client; pub mod error; pub mod models; +pub mod paradise; pub mod source; pub mod stream; diff --git a/pmoparadise/src/paradise/channel.rs b/pmoparadise/src/paradise/channel.rs new file mode 100644 index 00000000..e6ce98f5 --- /dev/null +++ b/pmoparadise/src/paradise/channel.rs @@ -0,0 +1,395 @@ +//! Channel orchestration primitives. +//! +//! This module wires together configuration, playlists, workers and client +//! tracking for a single Radio Paradise channel. The implementation is still +//! a scaffolding of the final behaviour; commands sent to the worker are +//! logged but not yet executing the full download/buffering pipeline. + +use super::config::RadioParadiseConfig; +use super::history::HistoryBackend; +use super::playlist::{PlaylistEntry, SharedPlaylist}; +use super::worker::{ParadiseWorker, WorkerCommand}; +use crate::client::RadioParadiseClient; +use anyhow::{Context, Result}; +use async_stream::try_stream; +use bytes::Bytes; +use futures::{stream::BoxStream, StreamExt}; +use pmosource::SourceCacheManager; +use std::fmt; +use std::str::FromStr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use tokio::fs::File; +use tokio::sync::{mpsc, Mutex}; +use tokio_util::io::ReaderStream; +use tracing::warn; + +/// Logical identifier for a Radio Paradise channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParadiseChannelKind { + Main, + Mellow, + Rock, + Eclectic, +} + +impl ParadiseChannelKind { + pub const fn id(self) -> u8 { + match self { + Self::Main => 0, + Self::Mellow => 1, + Self::Rock => 2, + Self::Eclectic => 3, + } + } + + pub const fn slug(self) -> &'static str { + match self { + Self::Main => "main", + Self::Mellow => "mellow", + Self::Rock => "rock", + Self::Eclectic => "eclectic", + } + } + + pub const fn display_name(self) -> &'static str { + match self { + Self::Main => "Main Mix", + Self::Mellow => "Mellow Mix", + Self::Rock => "Rock Mix", + Self::Eclectic => "Eclectic Mix", + } + } + + pub const fn description(self) -> &'static str { + match self { + Self::Main => "Eclectic mix of rock, world, electronica, and more", + Self::Mellow => "Mellower, less aggressive music", + Self::Rock => "Heavier, more guitar-driven music", + Self::Eclectic => "Curated worldwide selection", + } + } +} + +impl FromStr for ParadiseChannelKind { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result { + match s.to_ascii_lowercase().as_str() { + "main" | "0" => Ok(Self::Main), + "mellow" | "1" => Ok(Self::Mellow), + "rock" | "2" => Ok(Self::Rock), + "eclectic" | "3" => Ok(Self::Eclectic), + other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)), + } + } +} + +/// Metadata descriptor for a channel. +#[derive(Debug, Clone, Copy)] +pub struct ChannelDescriptor { + pub kind: ParadiseChannelKind, + pub id: u8, + pub slug: &'static str, + pub display_name: &'static str, + pub description: &'static str, +} + +impl ChannelDescriptor { + pub const fn new(kind: ParadiseChannelKind) -> Self { + Self { + id: kind.id(), + slug: kind.slug(), + display_name: kind.display_name(), + description: kind.description(), + kind, + } + } +} + +pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [ + ChannelDescriptor::new(ParadiseChannelKind::Main), + ChannelDescriptor::new(ParadiseChannelKind::Mellow), + ChannelDescriptor::new(ParadiseChannelKind::Rock), + ChannelDescriptor::new(ParadiseChannelKind::Eclectic), +]; + +/// Public handle to interact with a channel. +#[derive(Clone)] +pub struct ParadiseChannel { + inner: Arc, +} + +struct ParadiseChannelInner { + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: Arc, + playlist: SharedPlaylist, + history: Arc, + cache_manager: Arc, + active_clients: AtomicUsize, + worker_tx: mpsc::Sender, + worker: Mutex>, +} + +impl fmt::Debug for ParadiseChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ParadiseChannel") + .field("slug", &self.inner.descriptor.slug) + .field( + "active_clients", + &self.inner.active_clients.load(Ordering::SeqCst), + ) + .finish() + } +} + +impl ParadiseChannel { + #[allow(clippy::too_many_arguments)] + pub fn new( + descriptor: ChannelDescriptor, + base_client: RadioParadiseClient, + config: Arc, + history: Arc, + cache_manager: Arc, + ) -> Result { + let client = base_client.clone_with_channel(descriptor.id); + let playlist = SharedPlaylist::new(config.history.max_tracks); + let (worker, worker_tx) = ParadiseWorker::spawn( + descriptor, + client.clone(), + config.clone(), + playlist.clone(), + history.clone(), + cache_manager.clone(), + ); + + Ok(Self { + inner: Arc::new(ParadiseChannelInner { + descriptor, + client, + config, + playlist, + history, + cache_manager, + active_clients: AtomicUsize::new(0), + worker_tx, + worker: Mutex::new(Some(worker)), + }), + }) + } + + pub fn descriptor(&self) -> ChannelDescriptor { + self.inner.descriptor + } + + pub fn playlist(&self) -> &SharedPlaylist { + &self.inner.playlist + } + + pub fn config(&self) -> &Arc { + &self.inner.config + } + + pub fn history_backend(&self) -> &Arc { + &self.inner.history + } + + pub fn cache_manager(&self) -> Arc { + self.inner.cache_manager.clone() + } + + pub fn client(&self) -> &RadioParadiseClient { + &self.inner.client + } + + pub fn active_client_count(&self) -> usize { + self.inner.active_clients.load(Ordering::SeqCst) + } + + pub async fn connect_client( + &self, + client_id: impl Into, + ) -> Result { + let client_id = client_id.into(); + self.inner.active_clients.fetch_add(1, Ordering::SeqCst); + + if let Err(err) = self + .inner + .worker_tx + .send(WorkerCommand::ClientConnected { + client_id: client_id.clone(), + }) + .await + { + self.inner.active_clients.fetch_sub(1, Ordering::SeqCst); + return Err(anyhow::anyhow!("worker unavailable: {}", err)); + } + + self.inner.playlist.increment_all_pending().await; + self.ensure_started().await?; + + Ok(ParadiseClientStream::new(self.clone(), client_id)) + } + + pub async fn disconnect_client(&self, client_id: impl Into) -> Result<()> { + let client_id = client_id.into(); + self.inner.active_clients.fetch_sub(1, Ordering::SeqCst); + self.inner + .worker_tx + .send(WorkerCommand::ClientDisconnected { client_id }) + .await + .context("failed to notify worker of client disconnection")?; + Ok(()) + } + + pub async fn ensure_started(&self) -> Result<()> { + self.inner + .worker_tx + .send(WorkerCommand::EnsureReady) + .await + .context("failed to schedule worker warmup") + } + + pub async fn shutdown(&self) -> Result<()> { + self.inner + .worker_tx + .send(WorkerCommand::Shutdown) + .await + .ok(); + + let mut guard = self.inner.worker.lock().await; + if let Some(worker) = guard.take() { + worker + .wait() + .await + .context("failed to join worker task") + .map(|_| ()) + } else { + Ok(()) + } + } + + pub async fn mark_track_completed(&self, track: &Arc) { + let remaining = track.decrement_clients(); + if remaining > 0 { + return; + } + + if let Some(removed) = self + .inner + .playlist + .pop_front_matching(&track.track_id) + .await + { + if let Err(err) = self.inner.history.append(removed.as_history_entry()).await { + warn!( + channel = self.inner.descriptor.slug, + "Failed to persist history entry: {err:?}" + ); + } + + if let Err(err) = self + .inner + .history + .truncate(self.inner.config.history.max_tracks) + .await + { + warn!( + channel = self.inner.descriptor.slug, + "Failed to truncate history: {err:?}" + ); + } + + let history_entry = removed.as_history_entry(); + self.inner.playlist.push_history_entry(history_entry).await; + } + } +} + +/// Placeholder stream handle for per-client playback. +#[derive(Debug, Clone)] +pub struct ParadiseClientStream { + channel: ParadiseChannel, + client_id: String, +} + +impl ParadiseClientStream { + fn new(channel: ParadiseChannel, client_id: String) -> Self { + Self { channel, client_id } + } + + pub fn client_id(&self) -> &str { + &self.client_id + } + + pub fn channel(&self) -> ParadiseChannel { + self.channel.clone() + } + + pub fn into_byte_stream(self) -> BoxStream<'static, Result> { + let channel = self.channel.clone(); + let stream = try_stream! { + channel.ensure_started().await?; + let mut index = 0usize; + loop { + let entries = channel.playlist().active_snapshot().await; + + if index >= entries.len() { + channel.ensure_started().await?; + channel.playlist().wait_for_track_count(index).await; + continue; + } + + let entry = entries[index].clone(); + index += 1; + + let audio_pk = entry + .audio_pk + .clone() + .ok_or_else(|| anyhow::anyhow!("Audio not cached yet"))?; + + channel + .cache_manager() + .wait_audio_ready(&audio_pk) + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + let file_path = if let Some(path) = entry.file_path.clone() { + path + } else { + channel + .cache_manager() + .audio_file_path(&audio_pk) + .await + .ok_or_else(|| anyhow::anyhow!("Audio file path unavailable"))? + }; + + let file = File::open(&file_path).await?; + let mut reader = ReaderStream::new(file); + + while let Some(chunk) = reader.next().await { + let bytes = chunk?; + yield bytes; + } + + channel.mark_track_completed(&entry).await; + } + }; + + stream.boxed() + } +} + +impl Drop for ParadiseClientStream { + fn drop(&mut self) { + let channel = self.channel.clone(); + let client_id = self.client_id.clone(); + let slug = channel.descriptor().slug; + tokio::spawn(async move { + if let Err(err) = channel.disconnect_client(client_id).await { + warn!(channel = slug, "Failed to disconnect client: {err:?}"); + } + }); + } +} diff --git a/pmoparadise/src/paradise/config.rs b/pmoparadise/src/paradise/config.rs new file mode 100644 index 00000000..bb657050 --- /dev/null +++ b/pmoparadise/src/paradise/config.rs @@ -0,0 +1,317 @@ +//! Configuration structures for the Radio Paradise orchestration layer. +//! +//! The YAML schema is described in the functional specification. We expose +//! strongly typed structs with sensible defaults so the rest of the crate can +//! depend on a stable configuration shape irrespective of how the data is +//! loaded (embedded defaults, pmoconfig overrides, tests, etc.). + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Top-level configuration block. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RadioParadiseConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub channels: Vec, + #[serde(default)] + pub cache: CacheConfig, + #[serde(default)] + pub history: HistoryConfig, + #[serde(default)] + pub activity: ActivityConfig, + #[serde(default)] + pub polling: PollingConfig, + #[serde(default)] + pub stream: StreamConfig, + #[serde(default)] + pub api: ApiConfig, +} + +impl Default for RadioParadiseConfig { + fn default() -> Self { + Self { + enabled: true, + channels: vec![ + "main".to_string(), + "mellow".to_string(), + "rock".to_string(), + "eclectic".to_string(), + ], + cache: CacheConfig::default(), + history: HistoryConfig::default(), + activity: ActivityConfig::default(), + polling: PollingConfig::default(), + stream: StreamConfig::default(), + api: ApiConfig::default(), + } + } +} + +/// Cache related parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheConfig { + #[serde(default = "CacheConfig::default_max_blocks")] + pub max_blocks_remembered: usize, + #[serde(default = "CacheConfig::default_track_id_bytes")] + pub track_id_hash_bytes: usize, +} + +impl CacheConfig { + const fn default_max_blocks() -> usize { + 5 + } + + const fn default_track_id_bytes() -> usize { + 512 + } +} + +impl Default for CacheConfig { + fn default() -> Self { + Self { + max_blocks_remembered: Self::default_max_blocks(), + track_id_hash_bytes: Self::default_track_id_bytes(), + } + } +} + +/// Persisted history tuning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryConfig { + #[serde(default = "HistoryConfig::default_max_tracks")] + pub max_tracks: usize, + #[serde(default)] + pub persistence_backend: HistoryBackendKind, + #[serde(default = "HistoryConfig::default_database_path")] + pub database_path: String, +} + +impl HistoryConfig { + const fn default_max_tracks() -> usize { + 100 + } + + fn default_database_path() -> String { + "/var/lib/pmo/paradise_history.db".to_string() + } +} + +impl Default for HistoryConfig { + fn default() -> Self { + Self { + max_tracks: Self::default_max_tracks(), + persistence_backend: HistoryBackendKind::Sqlite, + database_path: Self::default_database_path(), + } + } +} + +impl RadioParadiseConfig { + pub fn load_from_pmoconfig() -> anyhow::Result { + let cfg = pmoconfig::get_config(); + match cfg.get_value(&["sources", "radio_paradise"]) { + Ok(value) => Ok(serde_yaml::from_value(value).unwrap_or_default()), + Err(_) => Ok(Self::default()), + } + } +} + +/// Backend selection for history persistence. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum HistoryBackendKind { + #[default] + Sqlite, + Json, +} + +/// Activity lifecycle tuning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivityConfig { + #[serde(default = "ActivityConfig::default_cooling_timeout")] + pub cooling_timeout_seconds: u64, +} + +impl ActivityConfig { + const fn default_cooling_timeout() -> u64 { + 180 + } + + pub fn cooling_timeout(&self) -> Duration { + Duration::from_secs(self.cooling_timeout_seconds) + } +} + +impl Default for ActivityConfig { + fn default() -> Self { + Self { + cooling_timeout_seconds: Self::default_cooling_timeout(), + } + } +} + +/// Polling strategy configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PollingConfig { + #[serde(default = "PollingConfig::default_interval_high")] + pub interval_high_buffer: u64, + #[serde(default = "PollingConfig::default_interval_medium")] + pub interval_medium_buffer: u64, + #[serde(default = "PollingConfig::default_interval_low")] + pub interval_low_buffer: u64, + #[serde(default)] + pub backoff_on_error: PollingBackoffConfig, +} + +impl PollingConfig { + const fn default_interval_high() -> u64 { + 120 + } + + const fn default_interval_medium() -> u64 { + 60 + } + + const fn default_interval_low() -> u64 { + 20 + } + + pub fn high_interval(&self) -> Duration { + Duration::from_secs(self.interval_high_buffer) + } + + pub fn medium_interval(&self) -> Duration { + Duration::from_secs(self.interval_medium_buffer) + } + + pub fn low_interval(&self) -> Duration { + Duration::from_secs(self.interval_low_buffer) + } +} + +impl Default for PollingConfig { + fn default() -> Self { + Self { + interval_high_buffer: Self::default_interval_high(), + interval_medium_buffer: Self::default_interval_medium(), + interval_low_buffer: Self::default_interval_low(), + backoff_on_error: PollingBackoffConfig::default(), + } + } +} + +/// Backoff policy for API errors. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PollingBackoffConfig { + #[serde(default = "PollingBackoffConfig::default_initial")] + pub initial: u64, + #[serde(default = "PollingBackoffConfig::default_max")] + pub max: u64, + #[serde(default = "PollingBackoffConfig::default_multiplier")] + pub multiplier: f32, +} + +impl PollingBackoffConfig { + const fn default_initial() -> u64 { + 20 + } + + const fn default_max() -> u64 { + 300 + } + + const fn default_multiplier() -> f32 { + 2.0 + } +} + +impl Default for PollingBackoffConfig { + fn default() -> Self { + Self { + initial: Self::default_initial(), + max: Self::default_max(), + multiplier: Self::default_multiplier(), + } + } +} + +/// Streaming pipeline configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamConfig { + #[serde(default = "StreamConfig::default_metadata_format")] + pub metadata_format: MetadataFormat, + #[serde(default)] + pub enable_gapless: bool, + #[serde(default = "StreamConfig::default_buffer_size")] + pub buffer_size_bytes: usize, +} + +impl StreamConfig { + fn default_metadata_format() -> MetadataFormat { + MetadataFormat::Icy + } + + const fn default_buffer_size() -> usize { + 64 * 1024 + } +} + +impl Default for StreamConfig { + fn default() -> Self { + Self { + metadata_format: MetadataFormat::Icy, + enable_gapless: true, + buffer_size_bytes: Self::default_buffer_size(), + } + } +} + +/// Metadata transport for streaming. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MetadataFormat { + Icy, + #[serde(other)] + None, +} + +/// Remote API tuning (timeouts, UA, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiConfig { + #[serde(default = "ApiConfig::default_base_url")] + pub base_url: String, + #[serde(default = "ApiConfig::default_timeout")] + pub timeout_seconds: u64, + #[serde(default = "ApiConfig::default_user_agent")] + pub user_agent: String, +} + +impl ApiConfig { + fn default_base_url() -> String { + "https://api.radioparadise.com".to_string() + } + + const fn default_timeout() -> u64 { + 30 + } + + fn default_user_agent() -> String { + "PMO-RadioParadise/1.0".to_string() + } + + pub fn timeout(&self) -> Duration { + Duration::from_secs(self.timeout_seconds) + } +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + base_url: Self::default_base_url(), + timeout_seconds: Self::default_timeout(), + user_agent: Self::default_user_agent(), + } + } +} diff --git a/pmoparadise/src/paradise/history.rs b/pmoparadise/src/paradise/history.rs new file mode 100644 index 00000000..ffce068e --- /dev/null +++ b/pmoparadise/src/paradise/history.rs @@ -0,0 +1,326 @@ +//! History persistence for Radio Paradise playback. +//! +//! The worker pushes every completed track into the history backend while +//! keeping the latest entries available for UPnP browsing. We expose an +//! abstract trait so different storage engines (SQLite, JSON, etc.) can be +//! supported while sharing the same API. + +use super::config::{HistoryBackendKind, HistoryConfig}; +use crate::models::Song; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::sync::Mutex; +use tokio::task::spawn_blocking; + +/// Serializable record describing a played track. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryEntry { + pub track_id: String, + pub channel_id: u8, + pub started_at: chrono::DateTime, + pub duration_ms: u64, + pub song: SongSnapshot, +} + +/// Minimal snapshot of a Radio Paradise song at playback time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SongSnapshot { + pub title: String, + pub artist: String, + pub album: Option, + pub cover_url: Option, +} + +impl SongSnapshot { + pub fn title(&self) -> &str { + &self.title + } +} + +impl From<&Song> for SongSnapshot { + fn from(song: &Song) -> Self { + Self { + title: song.title.clone(), + artist: song.artist.clone(), + album: song.album.clone(), + cover_url: song.cover.clone(), + } + } +} + +/// Abstract persistence interface. +#[async_trait] +pub trait HistoryBackend: Send + Sync { + async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()>; + async fn recent(&self, limit: usize) -> anyhow::Result>; + async fn len(&self) -> anyhow::Result; + async fn truncate(&self, keep: usize) -> anyhow::Result<()>; +} + +pub fn history_backend_from_config( + config: &HistoryConfig, +) -> anyhow::Result> { + match config.persistence_backend { + HistoryBackendKind::Sqlite => { + let backend = SqliteHistoryBackend::new(&config.database_path)?; + Ok(Arc::new(backend)) + } + HistoryBackendKind::Json => { + let backend = JsonHistoryBackend::new(&config.database_path); + Ok(Arc::new(backend)) + } + } +} + +/// Simple JSON file backed history (placeholder implementation). +/// +/// The JSON backend is primarily useful for tests and quick setups. The file +/// is stored next to the configured database path with a `.json` extension. +pub struct JsonHistoryBackend { + path: std::path::PathBuf, + entries: Arc>>, +} + +impl JsonHistoryBackend { + pub fn new(path: impl AsRef) -> Self { + let path = path.as_ref().to_path_buf(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let entries = if path.exists() { + std::fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() + } else { + Vec::new() + }; + + Self { + path, + entries: Arc::new(Mutex::new(entries)), + } + } + + async fn save(&self, entries: &[HistoryEntry]) -> anyhow::Result<()> { + let json = serde_json::to_vec_pretty(entries)?; + tokio::fs::write(&self.path, json).await?; + Ok(()) + } +} + +#[async_trait] +impl HistoryBackend for JsonHistoryBackend { + async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> { + let mut entries = self.entries.lock().await; + entries.push(entry); + self.save(&entries).await + } + + async fn recent(&self, limit: usize) -> anyhow::Result> { + let entries = self.entries.lock().await; + let total = entries.len(); + let start = total.saturating_sub(limit); + Ok(entries[start..].to_vec()) + } + + async fn len(&self) -> anyhow::Result { + Ok(self.entries.lock().await.len()) + } + + async fn truncate(&self, keep: usize) -> anyhow::Result<()> { + let mut entries = self.entries.lock().await; + if entries.len() > keep { + let drop_count = entries.len() - keep; + entries.drain(0..drop_count); + self.save(&entries).await?; + } + Ok(()) + } +} + +/// In-memory history backend – useful for tests or ephemeral deployments. +#[derive(Default)] +pub struct MemoryHistoryBackend { + entries: Arc>>, +} + +impl MemoryHistoryBackend { + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl HistoryBackend for MemoryHistoryBackend { + async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> { + let mut entries = self.entries.lock().await; + entries.push(entry); + Ok(()) + } + + async fn recent(&self, limit: usize) -> anyhow::Result> { + let entries = self.entries.lock().await; + let total = entries.len(); + let start = total.saturating_sub(limit); + Ok(entries[start..].to_vec()) + } + + async fn len(&self) -> anyhow::Result { + Ok(self.entries.lock().await.len()) + } + + async fn truncate(&self, keep: usize) -> anyhow::Result<()> { + let mut entries = self.entries.lock().await; + if entries.len() > keep { + let drop_count = entries.len() - keep; + entries.drain(0..drop_count); + } + Ok(()) + } +} + +pub struct SqliteHistoryBackend { + conn: Arc>, +} + +impl SqliteHistoryBackend { + pub fn new(path: impl AsRef) -> anyhow::Result { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = rusqlite::Connection::open(path)?; + conn.pragma_update(None, "journal_mode", &"WAL")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS paradise_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id TEXT NOT NULL, + channel_id INTEGER NOT NULL, + started_at_ms INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + cover_url TEXT + ); + CREATE INDEX IF NOT EXISTS idx_history_started_at ON paradise_history(started_at_ms);", + )?; + + Ok(Self { + conn: Arc::new(StdMutex::new(conn)), + }) + } + + fn conn(&self) -> Arc> { + self.conn.clone() + } +} + +#[async_trait] +impl HistoryBackend for SqliteHistoryBackend { + async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> { + let conn = self.conn(); + spawn_blocking(move || -> anyhow::Result<()> { + let conn = conn.lock().unwrap(); + conn.execute( + "INSERT INTO paradise_history (track_id, channel_id, started_at_ms, duration_ms, title, artist, album, cover_url) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + entry.track_id, + entry.channel_id as i64, + entry.started_at.timestamp_millis(), + entry.duration_ms as i64, + entry.song.title, + entry.song.artist, + entry.song.album, + entry.song.cover_url, + ], + )?; + Ok(()) + }) + .await??; + Ok(()) + } + + async fn recent(&self, limit: usize) -> anyhow::Result> { + let conn = self.conn(); + let limit = limit as i64; + spawn_blocking(move || -> anyhow::Result> { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT track_id, channel_id, started_at_ms, duration_ms, title, artist, album, cover_url + FROM paradise_history + ORDER BY started_at_ms DESC + LIMIT ?1", + )?; + + let mut rows = stmt.query([limit])?; + let mut entries = Vec::new(); + while let Some(row) = rows.next()? { + let started_at_ms: i64 = row.get(2)?; + let started_at = DateTime::::from_timestamp_millis(started_at_ms) + .ok_or_else(|| anyhow::anyhow!("Invalid timestamp in history"))?; + let entry = HistoryEntry { + track_id: row.get(0)?, + channel_id: row.get::<_, i64>(1)? as u8, + started_at, + duration_ms: row.get::<_, i64>(3)? as u64, + song: SongSnapshot { + title: row.get::<_, Option>(4)?.unwrap_or_default(), + artist: row.get::<_, Option>(5)?.unwrap_or_default(), + album: row.get(6)?, + cover_url: row.get(7)?, + }, + }; + entries.push(entry); + } + Ok(entries) + }) + .await? + } + + async fn len(&self) -> anyhow::Result { + let conn = self.conn(); + let count = spawn_blocking(move || -> anyhow::Result { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT COUNT(*) FROM paradise_history")?; + let count: i64 = stmt.query_row([], |row| row.get(0))?; + Ok(count as usize) + }) + .await??; + Ok(count) + } + + async fn truncate(&self, keep: usize) -> anyhow::Result<()> { + let conn = self.conn(); + spawn_blocking(move || -> anyhow::Result<()> { + let conn = conn.lock().unwrap(); + let count: i64 = + conn.query_row("SELECT COUNT(*) FROM paradise_history", [], |row| { + row.get(0) + })?; + let keep = keep as i64; + if count <= keep { + return Ok(()); + } + let to_remove = count - keep; + conn.execute( + "DELETE FROM paradise_history + WHERE id IN ( + SELECT id FROM paradise_history + ORDER BY started_at_ms ASC + LIMIT ?1 + )", + rusqlite::params![to_remove], + )?; + Ok(()) + }) + .await??; + Ok(()) + } +} diff --git a/pmoparadise/src/paradise/mod.rs b/pmoparadise/src/paradise/mod.rs new file mode 100644 index 00000000..fb6e8192 --- /dev/null +++ b/pmoparadise/src/paradise/mod.rs @@ -0,0 +1,33 @@ +//! Internal orchestration layer for dynamic Radio Paradise streaming. +//! +//! This module implements the high level structures described in the +//! Radio Paradise functional specification: +//! - `ParadiseChannel`: lifecycle and state machine for a single RP channel. +//! - `ParadiseWorker`: async task responsible for polling/downloading blocks. +//! - `ParadiseClientStream`: per-client audio stream with independent cursor. +//! - Shared caches and history storage hooked into existing PMO components. +//! +//! The implementation is split across several submodules to keep concerns +//! isolated (configuration, playlist management, history persistence, etc.). +//! The goal of this scaffolding is to provide a clear, testable surface for +//! the eventual end-to-end integration with the UPnP server and HTTP routes. + +mod channel; +mod config; +mod history; +mod playlist; +mod worker; + +pub use channel::{ + ChannelDescriptor, ParadiseChannel, ParadiseChannelKind, ParadiseClientStream, ALL_CHANNELS, +}; +pub use config::{ + ActivityConfig, ApiConfig, CacheConfig, HistoryConfig, PollingConfig, RadioParadiseConfig, + StreamConfig, +}; +pub use history::{ + history_backend_from_config, HistoryBackend, HistoryEntry, JsonHistoryBackend, + MemoryHistoryBackend, +}; +pub use playlist::PlaylistEntry; +pub use worker::{ParadiseWorker, WorkerCommand}; diff --git a/pmoparadise/src/paradise/playlist.rs b/pmoparadise/src/paradise/playlist.rs new file mode 100644 index 00000000..13a4fc89 --- /dev/null +++ b/pmoparadise/src/paradise/playlist.rs @@ -0,0 +1,293 @@ +//! Shared playlist structures for Radio Paradise channels. +//! +//! This module keeps track of the active queue and history for a Radio +//! Paradise channel. Each playlist entry knows how many clients still need +//! to consume it before the worker can evict it. + +use super::history::{HistoryEntry, SongSnapshot}; +use crate::models::Song; +use chrono::{DateTime, Utc}; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::{Notify, RwLock}; + +/// Metadata stored for an active track. +#[derive(Debug)] +pub struct PlaylistEntry { + pub track_id: String, + pub channel_id: u8, + pub song: Arc, + pub started_at: DateTime, + pub duration_ms: u64, + pub audio_pk: Option, + pub file_path: Option, + pending_clients: AtomicUsize, +} + +impl PlaylistEntry { + #[allow(clippy::too_many_arguments)] + pub fn new( + track_id: String, + channel_id: u8, + song: Arc, + started_at: DateTime, + duration_ms: u64, + audio_pk: Option, + file_path: Option, + pending_clients: usize, + ) -> Self { + Self { + track_id, + channel_id, + song, + started_at, + duration_ms, + audio_pk, + file_path, + pending_clients: AtomicUsize::new(pending_clients), + } + } + + pub fn as_history_entry(&self) -> HistoryEntry { + HistoryEntry { + track_id: self.track_id.clone(), + channel_id: self.channel_id, + started_at: self.started_at, + duration_ms: self.duration_ms, + song: SongSnapshot::from(self.song.as_ref()), + } + } + + pub fn pending_clients(&self) -> usize { + self.pending_clients.load(Ordering::SeqCst) + } + + pub fn set_pending_clients(&self, value: usize) { + self.pending_clients.store(value, Ordering::SeqCst); + } + + pub fn increment_clients(&self) -> usize { + self.pending_clients.fetch_add(1, Ordering::SeqCst) + 1 + } + + pub fn decrement_clients(&self) -> usize { + let mut current = self.pending_clients.load(Ordering::SeqCst); + loop { + if current == 0 { + return 0; + } + match self.pending_clients.compare_exchange( + current, + current - 1, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => return current - 1, + Err(actual) => current = actual, + } + } + } +} + +#[derive(Default)] +struct PlaylistState { + active: VecDeque>, + history: VecDeque, + max_history: usize, +} + +impl PlaylistState { + fn new(max_history: usize) -> Self { + Self { + active: VecDeque::new(), + history: VecDeque::new(), + max_history, + } + } + + fn active_len(&self) -> usize { + self.active.len() + } + + fn push_active(&mut self, entry: Arc) { + self.active.push_back(entry); + } + + fn active_snapshot(&self) -> Vec> { + self.active.iter().cloned().collect() + } + + fn pop_front_if_ready(&mut self) -> Option> { + if let Some(front) = self.active.front() { + if front.pending_clients() == 0 { + return self.active.pop_front(); + } + } + None + } + + fn pop_front_matching(&mut self, track_id: &str) -> Option> { + if let Some(front) = self.active.front() { + if front.track_id == track_id && front.pending_clients() == 0 { + return self.active.pop_front(); + } + } + None + } + + fn push_history(&mut self, entry: HistoryEntry) { + self.history.push_back(entry); + self.trim_history(); + } + + fn recent_history(&self, limit: usize) -> Vec { + let total = self.history.len(); + let start = total.saturating_sub(limit); + self.history.iter().skip(start).cloned().collect() + } + + fn trim_history(&mut self) { + while self.history.len() > self.max_history { + self.history.pop_front(); + } + } + + fn clear(&mut self) -> bool { + let changed = !self.active.is_empty() || !self.history.is_empty(); + if changed { + self.active.clear(); + self.history.clear(); + } + changed + } + + fn increment_all(&self) { + for entry in &self.active { + entry.increment_clients(); + } + } +} + +struct SharedPlaylistInner { + state: RwLock, + notify: Notify, + update_id: AtomicU32, + last_change: RwLock>, +} + +#[derive(Clone)] +pub struct SharedPlaylist(Arc); + +impl SharedPlaylist { + pub fn new(max_history: usize) -> Self { + Self(Arc::new(SharedPlaylistInner { + state: RwLock::new(PlaylistState::new(max_history)), + notify: Notify::new(), + update_id: AtomicU32::new(0), + last_change: RwLock::new(None), + })) + } + + async fn touch(&self) { + self.0.update_id.fetch_add(1, Ordering::SeqCst); + let mut last_change = self.0.last_change.write().await; + *last_change = Some(SystemTime::now()); + } + + pub async fn push_active(&self, entry: Arc) { + let mut guard = self.0.state.write().await; + guard.push_active(entry); + drop(guard); + self.touch().await; + self.0.notify.notify_waiters(); + } + + pub async fn active_len(&self) -> usize { + let guard = self.0.state.read().await; + guard.active_len() + } + + pub async fn active_snapshot(&self) -> Vec> { + let guard = self.0.state.read().await; + guard.active_snapshot() + } + + pub async fn clear(&self) { + let mut guard = self.0.state.write().await; + let changed = guard.clear(); + drop(guard); + if changed { + self.touch().await; + self.0.notify.notify_waiters(); + } + } + + pub async fn wait_for_track_count(&self, current_len: usize) { + loop { + let len = { + let guard = self.0.state.read().await; + guard.active_len() + }; + + if len > current_len { + break; + } + + self.0.notify.notified().await; + } + } + + pub async fn pop_front_if_ready(&self) -> Option> { + let mut guard = self.0.state.write().await; + let result = guard.pop_front_if_ready(); + drop(guard); + + if result.is_some() { + self.touch().await; + self.0.notify.notify_waiters(); + } + + result + } + + pub async fn pop_front_matching(&self, track_id: &str) -> Option> { + let mut guard = self.0.state.write().await; + let result = guard.pop_front_matching(track_id); + drop(guard); + + if result.is_some() { + self.touch().await; + self.0.notify.notify_waiters(); + } + + result + } + + pub async fn push_history_entry(&self, entry: HistoryEntry) { + let mut guard = self.0.state.write().await; + guard.push_history(entry); + drop(guard); + self.touch().await; + } + + pub async fn recent_history(&self, limit: usize) -> Vec { + let guard = self.0.state.read().await; + guard.recent_history(limit) + } + + pub async fn increment_all_pending(&self) { + let guard = self.0.state.read().await; + guard.increment_all(); + } + + pub fn update_id(&self) -> u32 { + self.0.update_id.load(Ordering::SeqCst) + } + + pub async fn last_change(&self) -> Option { + self.0.last_change.read().await.clone() + } +} diff --git a/pmoparadise/src/paradise/worker.rs b/pmoparadise/src/paradise/worker.rs new file mode 100644 index 00000000..b4cdf4dc --- /dev/null +++ b/pmoparadise/src/paradise/worker.rs @@ -0,0 +1,758 @@ +//! Background worker for Radio Paradise channels. +//! +//! The worker handles API polling, block ingestion, caching and playlist +//! maintenance. It keeps the channel state in sync with connected clients +//! and ensures fresh content is available according to the specification. + +use super::channel::ChannelDescriptor; +use super::config::RadioParadiseConfig; +use super::history::HistoryBackend; +use super::playlist::{PlaylistEntry, SharedPlaylist}; +use crate::client::RadioParadiseClient; +use crate::models::{Block, Song}; +use anyhow::{anyhow, Context, Result}; +use bytes::Bytes; +use chrono::Utc; +use futures::stream; +use pmosource::{SourceCacheManager, TrackMetadata}; +use sha2::{Digest, Sha256}; +use std::collections::{HashSet, VecDeque}; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; +use tokio_util::io::StreamReader; +use tracing::{debug, error, info, warn}; +use url::Url; + +/// Commands sent to the background worker. +#[derive(Debug)] +pub enum WorkerCommand { + EnsureReady, + ClientConnected { client_id: String }, + ClientDisconnected { client_id: String }, + RefreshBlock, + Shutdown, +} + +/// Handle to the spawned worker task. +pub struct ParadiseWorker { + descriptor: ChannelDescriptor, + join_handle: JoinHandle<()>, +} + +impl ParadiseWorker { + #[allow(clippy::too_many_arguments)] + pub fn spawn( + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: Arc, + playlist: SharedPlaylist, + history: Arc, + cache_manager: Arc, + ) -> (Self, mpsc::Sender) { + let (tx, mut rx) = mpsc::channel(32); + + let join_handle = tokio::spawn(async move { + info!(channel = descriptor.slug, "Starting Radio Paradise worker"); + + let mut state = + WorkerState::new(descriptor, client, config, playlist, history, cache_manager); + + loop { + if let Some(task) = state.scheduled_task.as_mut() { + let kind = task.kind; + let mut pending_command: Option> = None; + + tokio::select! { + cmd = rx.recv() => { + pending_command = Some(cmd); + } + _ = &mut task.sleep => { + state.scheduled_task = None; + if let Err(err) = state.handle_scheduled_task(kind).await { + error!(channel = state.descriptor.slug, "Worker scheduled task error: {err:?}"); + state.on_error(err); + } + } + } + + if let Some(Some(cmd)) = pending_command { + if let Err(err) = state.handle_command(cmd).await { + error!( + channel = state.descriptor.slug, + "Worker command error: {err:?}" + ); + state.on_error(err); + } + if state.shutdown { + break; + } + } else if let Some(None) = pending_command { + // Command channel closed, terminate + break; + } + } else { + match rx.recv().await { + Some(cmd) => { + if let Err(err) = state.handle_command(cmd).await { + error!( + channel = state.descriptor.slug, + "Worker command error: {err:?}" + ); + state.on_error(err); + } + if state.shutdown { + break; + } + } + None => break, + } + } + } + + info!(channel = state.descriptor.slug, "Worker stopped"); + }); + + ( + Self { + descriptor, + join_handle, + }, + tx, + ) + } + + pub async fn wait(self) -> Result<()> { + if let Err(err) = self.join_handle.await { + if err.is_cancelled() { + warn!( + channel = self.descriptor.slug, + "Worker task cancelled: {err}" + ); + return Ok(()); + } + return Err(anyhow!("Worker join error: {}", err)); + } + Ok(()) + } +} + +struct WorkerState { + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: Arc, + playlist: SharedPlaylist, + history: Arc, + cache_manager: Arc, + active_clients: usize, + status: ChannelLifecycle, + processed_blocks: HashSet, + recent_blocks: VecDeque, + next_block_hint: Option, + scheduled_task: Option, + backoff: BackoffState, + shutdown: bool, +} + +impl WorkerState { + fn new( + descriptor: ChannelDescriptor, + client: RadioParadiseClient, + config: Arc, + playlist: SharedPlaylist, + history: Arc, + cache_manager: Arc, + ) -> Self { + Self { + descriptor, + client, + config, + playlist, + history, + cache_manager, + active_clients: 0, + status: ChannelLifecycle::Idle, + processed_blocks: HashSet::new(), + recent_blocks: VecDeque::new(), + next_block_hint: None, + scheduled_task: None, + backoff: BackoffState::new(), + shutdown: false, + } + } + + async fn handle_command(&mut self, cmd: WorkerCommand) -> Result<()> { + debug!(channel = self.descriptor.slug, ?cmd, "Worker command"); + + match cmd { + WorkerCommand::EnsureReady => { + self.ensure_ready().await?; + } + WorkerCommand::ClientConnected { .. } => { + self.active_clients = self.active_clients.saturating_add(1); + self.enter_active(); + self.ensure_ready().await?; + } + WorkerCommand::ClientDisconnected { .. } => { + self.active_clients = self.active_clients.saturating_sub(1); + if self.active_clients == 0 { + self.enter_cooling(); + } + } + WorkerCommand::RefreshBlock => { + self.fetch_next_block().await?; + } + WorkerCommand::Shutdown => { + self.shutdown = true; + self.cancel_scheduled_task(); + } + } + + if !self.shutdown { + self.maybe_schedule_poll().await; + } + + Ok(()) + } + + async fn handle_scheduled_task(&mut self, kind: ScheduledTaskKind) -> Result<()> { + match kind { + ScheduledTaskKind::Poll => { + self.fetch_next_block().await?; + self.maybe_schedule_poll().await; + } + ScheduledTaskKind::Cooling => { + debug!( + channel = self.descriptor.slug, + "Cooling timeout reached -> idle" + ); + self.status = ChannelLifecycle::Idle; + self.next_block_hint = None; + self.playlist.clear().await; + self.processed_blocks.clear(); + self.recent_blocks.clear(); + } + } + Ok(()) + } + + fn on_error(&mut self, err: anyhow::Error) { + warn!(channel = self.descriptor.slug, "Worker error: {err:?}"); + let delay = self + .backoff + .next_delay(&self.config.polling.backoff_on_error); + self.schedule_task(ScheduledTaskKind::Poll, delay); + } + + fn enter_active(&mut self) { + if !matches!(self.status, ChannelLifecycle::Active) { + debug!( + channel = self.descriptor.slug, + "Channel entering Active state" + ); + } + self.status = ChannelLifecycle::Active; + if matches!(self.scheduled_task_kind(), Some(ScheduledTaskKind::Cooling)) { + self.cancel_scheduled_task(); + } + self.backoff.reset(); + } + + fn enter_cooling(&mut self) { + if matches!(self.status, ChannelLifecycle::Idle) { + return; + } + debug!( + channel = self.descriptor.slug, + "Channel entering Cooling state" + ); + self.status = ChannelLifecycle::Cooling; + let duration = Duration::from_secs(self.config.activity.cooling_timeout_seconds.max(1)); + self.schedule_task(ScheduledTaskKind::Cooling, duration); + } + + async fn ensure_ready(&mut self) -> Result<()> { + if !matches!(self.status, ChannelLifecycle::Active) { + self.enter_active(); + } + + let has_tracks = self.playlist.active_len().await > 0; + + if !has_tracks { + debug!( + channel = self.descriptor.slug, + "Playlist empty – fetching now playing" + ); + let now_playing = self.client.now_playing().await?; + self.process_block(now_playing.block).await?; + } + + Ok(()) + } + + async fn fetch_next_block(&mut self) -> Result<()> { + if !matches!(self.status, ChannelLifecycle::Active) { + debug!( + channel = self.descriptor.slug, + "Skipping poll while not active" + ); + return Ok(()); + } + + let event_id = self.next_block_hint; + let block = self.client.get_block(event_id).await?; + self.process_block(block).await?; + Ok(()) + } + + async fn process_block(&mut self, block: Block) -> Result<()> { + if self.is_recent_block(block.event) { + debug!( + channel = self.descriptor.slug, + event = block.event, + "Skipping already processed block" + ); + self.next_block_hint = Some(block.end_event); + return Ok(()); + } + + info!( + channel = self.descriptor.slug, + event = block.event, + "Processing Radio Paradise block" + ); + + let _ = &self.history; + + let block_url = Url::parse(&block.url)?; + let block_bytes = self + .client + .download_block(&block_url) + .await + .context("Failed to download block")?; + + let decoded = decode_block_audio(block_bytes.to_vec())?; + let ordered_songs = block.songs_ordered(); + let total_frames = decoded.samples.len() / decoded.channels; + + for (position, (song_index, song)) in ordered_songs.iter().enumerate() { + let track = self + .process_song( + &block, + song_index, + song, + position, + &ordered_songs, + total_frames, + &decoded, + ) + .await?; + + self.playlist.push_active(track.clone()).await; + } + + self.record_processed_block(block.event); + self.next_block_hint = Some(block.end_event); + self.backoff.reset(); + + Ok(()) + } + + async fn process_song( + &self, + block: &Block, + song_index: &usize, + song: &Song, + position: usize, + ordered_songs: &[(usize, &Song)], + total_frames: usize, + decoded: &DecodedBlock, + ) -> Result> { + let duration_ms = song_duration_ms(block, ordered_songs, position); + let start_frame = ms_to_frames(song.elapsed, decoded.sample_rate); + let end_frame = if position + 1 < ordered_songs.len() { + ms_to_frames(ordered_songs[position + 1].1.elapsed, decoded.sample_rate) + } else { + total_frames + }; + + if end_frame <= start_frame || end_frame > total_frames { + warn!( + channel = self.descriptor.slug, + song_index = song_index, + "Invalid frame range for song, skipping" + ); + return Err(anyhow!("Invalid frame range")); + } + + let channels = decoded.channels; + let start = start_frame * channels; + let end = end_frame * channels; + let slice = decoded + .samples + .get(start..end) + .ok_or_else(|| anyhow!("Sample slice out of bounds"))?; + + let track_samples = slice.to_vec(); + let flac_bytes = encode_samples_to_flac( + track_samples, + decoded.channels, + decoded.sample_rate, + decoded.bits_per_sample, + ) + .await + .context("Failed to encode song to FLAC")?; + + let track_id = self.compute_track_id(&flac_bytes); + let placeholder_uri = format!("{}#{}", block.url, song_index); + + let mut metadata = TrackMetadata { + original_uri: placeholder_uri.clone(), + cached_audio_pk: None, + cached_cover_pk: None, + }; + + if let Some(cover_pk) = self.cache_cover(block, song).await? { + metadata.cached_cover_pk = Some(cover_pk); + } + + let flac_len = flac_bytes.len() as u64; + let reader = StreamReader::new(stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + flac_bytes, + ))])); + + let audio_pk = self + .cache_manager + .cache_audio_from_reader(&track_id, reader, Some(flac_len)) + .await + .map_err(|e| anyhow!("Cache audio error: {e}"))?; + + metadata.cached_audio_pk = Some(audio_pk.clone()); + self.cache_manager + .update_metadata(track_id.clone(), metadata) + .await; + + let file_path = self.cache_manager.audio_file_path(&audio_pk).await; + + let entry = Arc::new(PlaylistEntry::new( + track_id, + self.descriptor.id, + Arc::new(song.clone()), + Utc::now(), + duration_ms, + Some(audio_pk), + file_path, + self.active_clients, + )); + + Ok(entry) + } + + async fn cache_cover(&self, block: &Block, song: &Song) -> Result> { + if let Some(ref cover_path) = song.cover { + let cover_url = + resolve_cover_url(block.image_base.as_deref(), &self.client, cover_path) + .context("Invalid cover URL")?; + match self.cache_manager.cache_cover(cover_url.as_str()).await { + Ok(pk) => return Ok(Some(pk)), + Err(err) => { + warn!(channel = self.descriptor.slug, "Cover cache error: {err}"); + } + } + } + Ok(None) + } + + fn compute_track_id(&self, flac_bytes: &[u8]) -> String { + let slice_len = flac_bytes.len().min(self.config.cache.track_id_hash_bytes); + let mut hasher = Sha256::new(); + hasher.update(&flac_bytes[..slice_len]); + let hash = hasher.finalize(); + format!("rp:{}:{}", self.descriptor.id, hex::encode(hash)) + } + + async fn maybe_schedule_poll(&mut self) { + if !matches!(self.status, ChannelLifecycle::Active) { + return; + } + + let buffer_len = self.playlist.active_len().await; + + let interval = if buffer_len > 3 { + self.config.polling.high_interval() + } else if buffer_len >= 2 { + self.config.polling.medium_interval() + } else { + self.config.polling.low_interval() + }; + + self.schedule_task(ScheduledTaskKind::Poll, interval); + } + + fn schedule_task(&mut self, kind: ScheduledTaskKind, duration: Duration) { + self.scheduled_task = Some(ScheduledTask { + kind, + sleep: Box::pin(sleep(duration)), + }); + } + + fn cancel_scheduled_task(&mut self) { + self.scheduled_task = None; + } + + fn scheduled_task_kind(&self) -> Option { + self.scheduled_task.as_ref().map(|task| task.kind) + } + + fn record_processed_block(&mut self, event: u64) { + self.processed_blocks.insert(event); + self.recent_blocks.push_back(event); + let max = self.config.cache.max_blocks_remembered.max(1); + while self.recent_blocks.len() > max { + if let Some(ev) = self.recent_blocks.pop_front() { + self.processed_blocks.remove(&ev); + } + } + } + + fn is_recent_block(&self, event: u64) -> bool { + self.processed_blocks.contains(&event) + } +} + +struct ScheduledTask { + kind: ScheduledTaskKind, + sleep: Pin>, +} + +#[derive(Clone, Copy)] +enum ScheduledTaskKind { + Poll, + Cooling, +} + +#[derive(Clone, Copy, Debug)] +enum ChannelLifecycle { + Idle, + Cooling, + Active, +} + +struct BackoffState { + current: Option, +} + +impl BackoffState { + fn new() -> Self { + Self { current: None } + } + + fn reset(&mut self) { + self.current = None; + } + + fn next_delay(&mut self, config: &super::config::PollingBackoffConfig) -> Duration { + let next = match self.current { + Some(current) => { + let multiplied = (current.as_secs_f32() * config.multiplier).round() as u64; + Duration::from_secs(multiplied.min(config.max)) + } + None => Duration::from_secs(config.initial), + }; + self.current = Some(next); + next + } +} + +struct DecodedBlock { + samples: Vec, + channels: usize, + sample_rate: u32, + bits_per_sample: u32, +} + +fn song_duration_ms(block: &Block, ordered: &[(usize, &Song)], position: usize) -> u64 { + let song = ordered[position].1; + if song.duration > 0 { + return song.duration; + } + + if let Some((_, next_song)) = ordered.get(position + 1) { + return next_song.elapsed.saturating_sub(song.elapsed); + } + + block.length.saturating_sub(song.elapsed) +} + +fn ms_to_frames(ms: u64, sample_rate: u32) -> usize { + ((ms as u128 * sample_rate as u128) / 1000) as usize +} + +fn decode_block_audio(data: Vec) -> anyhow::Result { + use symphonia::core::audio::SampleBuffer; + use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; + use symphonia::core::errors::Error as SymphoniaError; + use symphonia::core::formats::FormatOptions; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + use symphonia::core::probe::Hint; + + let cursor = std::io::Cursor::new(data); + let mss = MediaSourceStream::new(Box::new(cursor), Default::default()); + + let hint = Hint::new(); + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|e| anyhow!("Failed to probe format: {e}"))?; + + let mut format = probed.format; + + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| anyhow!("No audio track found"))?; + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|e| anyhow!("Failed to create decoder: {e}"))?; + + let channels = track + .codec_params + .channels + .ok_or_else(|| anyhow!("Missing channel info"))? + .count(); + + let sample_rate = track + .codec_params + .sample_rate + .ok_or_else(|| anyhow!("Missing sample rate"))?; + + let bits_per_sample = track.codec_params.bits_per_sample.unwrap_or(16); + + let mut samples_i32 = Vec::new(); + let track_id = track.id; + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(SymphoniaError::ResetRequired) => { + decoder.reset(); + continue; + } + Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + break; + } + Err(e) => return Err(anyhow!("Decode error: {e}")), + }; + + if packet.track_id() != track_id { + continue; + } + + match decoder.decode(&packet) { + Ok(decoded) => { + let spec = *decoded.spec(); + let duration = decoded.capacity() as u64; + let mut sample_buf = SampleBuffer::::new(duration, spec); + sample_buf.copy_interleaved_ref(decoded); + samples_i32.extend_from_slice(sample_buf.samples()); + } + Err(SymphoniaError::DecodeError(_)) => continue, + Err(e) => return Err(anyhow!("Decode error: {e}")), + } + } + + if samples_i32.is_empty() { + return Err(anyhow!("No samples decoded")); + } + + let (normalized_samples, target_bits): (Vec, u32) = match bits_per_sample { + 0..=16 => { + let samples = samples_i32.iter().map(|&s| (s >> 16) as i32).collect(); + (samples, 16) + } + 17..=24 => { + let samples = samples_i32.iter().map(|&s| (s >> 8) as i32).collect(); + (samples, 24) + } + _ => (samples_i32, 32), + }; + + Ok(DecodedBlock { + samples: normalized_samples, + channels, + sample_rate, + bits_per_sample: target_bits, + }) +} + +async fn encode_samples_to_flac( + samples: Vec, + channels: usize, + sample_rate: u32, + bits_per_sample: u32, +) -> anyhow::Result> { + tokio::task::spawn_blocking(move || { + use flacenc::bitsink::ByteSink; + use flacenc::component::BitRepr; + use flacenc::error::Verify; + + let config = flacenc::config::Encoder::default() + .into_verified() + .map_err(|e| anyhow!("FLAC config error: {e:?}"))?; + + let source = flacenc::source::MemSource::from_samples( + &samples, + channels, + bits_per_sample as usize, + sample_rate as usize, + ); + + let flac_stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size) + .map_err(|e| anyhow!("FLAC encode error: {e:?}"))?; + + let mut sink = ByteSink::new(); + flac_stream + .write(&mut sink) + .map_err(|e| anyhow!("FLAC write error: {e:?}"))?; + + Ok::<_, anyhow::Error>(sink.into_inner()) + }) + .await? +} + +fn resolve_cover_url( + image_base: Option<&str>, + client: &RadioParadiseClient, + cover: &str, +) -> Result { + if cover.starts_with("http://") || cover.starts_with("https://") { + return Url::parse(cover).map_err(|e| anyhow!("Invalid cover URL '{cover}': {e}")); + } + + if cover.starts_with("//") { + let url = format!("https:{cover}"); + return Url::parse(&url).map_err(|e| anyhow!("Invalid cover URL '{cover}': {e}")); + } + + if let Some(base) = image_base { + match Url::parse(base).and_then(|base_url| base_url.join(cover)) { + Ok(url) => return Ok(url), + Err(err) => { + debug!("Failed to join cover '{cover}' with base '{base}': {err}"); + } + } + } + + client + .cover_url(cover) + .map_err(|e| anyhow!("Invalid cover URL '{cover}': {e}")) +} diff --git a/pmoparadise/src/pmoserver_ext.rs b/pmoparadise/src/pmoserver_ext.rs index 7de0e084..931c2406 100644 --- a/pmoparadise/src/pmoserver_ext.rs +++ b/pmoparadise/src/pmoserver_ext.rs @@ -3,22 +3,31 @@ //! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise //! Ă  un serveur pmoserver. -use crate::{models::Bitrate, Block, NowPlaying, RadioParadiseClient}; +use crate::paradise::{ParadiseChannel, PlaylistEntry}; +use crate::{models::Bitrate, Block, NowPlaying, RadioParadiseClient, RadioParadiseSource}; use axum::{ + body::Body, extract::{Path, Query, State}, - http::StatusCode, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, + response::IntoResponse, routing::get, Json, Router, }; +use chrono::{DateTime, Utc}; +use futures::StreamExt; +use pmosource::api::CacheStatusInfo; +use pmosource::CacheStatus; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; -use utoipa::{OpenApi, ToSchema}; +use tracing::error; +use utoipa::{IntoParams, OpenApi, ToSchema}; /// État partagĂ© pour l'API Radio Paradise #[derive(Clone)] pub struct RadioParadiseState { client: Arc>, + source: Arc, } const MAX_CHANNEL_ID: u8 = 3; @@ -30,13 +39,53 @@ struct ParadiseQuery { bitrate: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct StreamQuery { + channel: Option, + client_id: Option, +} + +#[derive(Debug, Default, Deserialize, IntoParams)] +#[serde(default)] +#[into_params(parameter_in = Query)] +struct ListLimitQuery { + /// Nombre maximum d'Ă©lĂ©ments Ă  retourner (0 = tous) + #[serde(default)] + limit: Option, +} + impl RadioParadiseState { pub async fn new() -> anyhow::Result { let client = RadioParadiseClient::new() .await .map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?; + #[cfg(feature = "server")] + let source = RadioParadiseSource::from_registry_default(client.clone()) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + + #[cfg(not(feature = "server"))] + let source = { + let base_dir = std::env::temp_dir().join("pmoparadise_api"); + let cover_dir = base_dir.join("covers"); + let audio_dir = base_dir.join("audio"); + std::fs::create_dir_all(&cover_dir)?; + std::fs::create_dir_all(&audio_dir)?; + + let cover_cache = Arc::new(pmocovers::cache::new_cache( + cover_dir.to_string_lossy().as_ref(), + 256, + )?); + let audio_cache = Arc::new(pmoaudiocache::cache::new_cache( + audio_dir.to_string_lossy().as_ref(), + 256, + )?); + RadioParadiseSource::new_default(client.clone(), cover_cache, audio_cache) + }; + Ok(Self { client: Arc::new(RwLock::new(client)), + source: Arc::new(source), }) } @@ -69,6 +118,19 @@ impl RadioParadiseState { Ok(client) } + + fn channel_for_id(&self, channel_id: u8) -> Result, StatusCode> { + if channel_id > MAX_CHANNEL_ID { + return Err(StatusCode::BAD_REQUEST); + } + self.source + .channel(channel_id) + .ok_or(StatusCode::SERVICE_UNAVAILABLE) + } + + pub fn source(&self) -> Arc { + self.source.clone() + } } /// Information sur un canal Radio Paradise @@ -345,6 +407,145 @@ pub struct BitrateInfo { pub name: String, } +/// Statut opĂ©rationnel d'un canal Radio Paradise +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ChannelStatusResponse { + /// ID numĂ©rique du canal + pub channel_id: u8, + /// Slug du canal (main, mellow, ...) + pub slug: String, + /// Nom complet du canal + pub name: String, + /// Description + pub description: String, + /// Nombre de clients connectĂ©s au flux + pub active_clients: usize, + /// Nombre de morceaux prĂ©sents dans la file d'attente + pub queue_length: usize, + /// Valeur courante d'update_id + pub update_id: u32, + /// Dernière modification (RFC3339) + pub last_change: Option, + /// Nombre total d'entrĂ©es en historique (persistĂ©) + pub history_entries: usize, + /// Limite configurĂ©e pour l'historique + pub history_max_tracks: usize, + /// Le canal est-il activĂ© dans la configuration ? + pub configured: bool, + /// Identifiant de collection pour le cache + pub cache_collection_id: String, + /// Nombre total de pistes connues du cache + pub cache_total_tracks: usize, + /// Nombre de pistes dĂ©jĂ  en cache + pub cache_cached_tracks: usize, +} + +/// EntrĂ©e dĂ©taillĂ©e de la file d'attente +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ChannelPlaylistEntry { + /// Position dans la file + pub index: usize, + /// ID unique de la piste + pub track_id: String, + /// ID du canal + pub channel_id: u8, + /// Titre du morceau + pub title: String, + /// Artiste + pub artist: String, + /// Album + pub album: Option, + /// URL de couverture (si disponible) + pub cover_url: Option, + /// DurĂ©e du morceau en ms + pub duration_ms: u64, + /// Offset dans le block (ms) + pub elapsed_ms: u64, + /// Horodatage prĂ©vu/dĂ©marrĂ© (RFC3339) + pub started_at: String, + /// Nombre de clients restants Ă  servir + pub pending_clients: usize, + /// Note Ă©ventuelle (0-10) + pub rating: Option, + /// AnnĂ©e Ă©ventuelle + pub year: Option, + /// Statut de cache + pub cache_status: CacheStatusInfo, +} + +impl ChannelPlaylistEntry { + fn from_entry(entry: &Arc, index: usize, cache_status: CacheStatusInfo) -> Self { + let song = entry.song.as_ref(); + Self { + index, + track_id: entry.track_id.clone(), + channel_id: entry.channel_id, + title: song.title.clone(), + artist: song.artist.clone(), + album: song.album.clone(), + cover_url: song.cover.clone(), + duration_ms: entry.duration_ms, + elapsed_ms: song.elapsed, + started_at: entry.started_at.to_rfc3339(), + pending_clients: entry.pending_clients(), + rating: song.rating, + year: song.year, + cache_status, + } + } +} + +/// RĂ©ponse pour la file d'attente d'un canal +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ChannelPlaylistResponse { + /// ID du canal + pub channel_id: u8, + /// Slug du canal + pub slug: String, + /// Update ID du playlist + pub update_id: u32, + /// Taille totale de la file au moment de la capture + pub queue_length: usize, + /// EntrĂ©es retournĂ©es + pub items: Vec, +} + +/// EntrĂ©e d'historique d'Ă©coute +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ChannelHistoryEntry { + /// ID unique de la piste + pub track_id: String, + /// ID du canal + pub channel_id: u8, + /// Titre + pub title: String, + /// Artiste + pub artist: String, + /// Album + pub album: Option, + /// URL de couverture + pub cover_url: Option, + /// DĂ©but de lecture (RFC3339) + pub started_at: String, + /// DurĂ©e en ms + pub duration_ms: u64, +} + +/// RĂ©ponse pour l'historique d'un canal +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ChannelHistoryResponse { + /// ID du canal + pub channel_id: u8, + /// Slug du canal + pub slug: String, + /// Nombre total d'entrĂ©es disponibles + pub total_available: usize, + /// Nombre d'entrĂ©es retournĂ©es dans cette rĂ©ponse + pub returned: usize, + /// EntrĂ©es + pub entries: Vec, +} + /// GET /bitrates - Liste les bitrates disponibles #[utoipa::path( get, @@ -381,6 +582,262 @@ async fn get_bitrates() -> Json> { Json(bitrates) } +/// GET /channels/{channel_id}/status - Statut dĂ©taillĂ© d'un canal +#[utoipa::path( + get, + path = "/channels/{channel_id}/status", + params( + ("channel_id" = u8, Path, description = "Channel ID (0-3)") + ), + responses( + (status = 200, description = "Statut du canal", body = ChannelStatusResponse), + (status = 400, description = "Canal invalide"), + (status = 503, description = "Canal indisponible"), + (status = 500, description = "Erreur interne lors de la rĂ©cupĂ©ration du statut") + ), + tag = "Radio Paradise" +)] +async fn get_channel_status( + State(state): State, + Path(channel_id): Path, +) -> Result, StatusCode> { + let channel = state.channel_for_id(channel_id)?; + let descriptor = channel.descriptor(); + + let playlist = channel.playlist(); + let queue_length = playlist.active_len().await; + let update_id = playlist.update_id(); + let last_change = playlist + .last_change() + .await + .map(|ts| DateTime::::from(ts).to_rfc3339()); + + let history_len = channel.history_backend().len().await.map_err(|e| { + error!( + channel = descriptor.slug, + "Failed to retrieve history size: {e:?}" + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let cache_stats = channel.cache_manager().statistics().await; + let config = channel.config().clone(); + let configured = config.channels.iter().any(|slug| slug == descriptor.slug); + + let status = ChannelStatusResponse { + channel_id, + slug: descriptor.slug.to_string(), + name: descriptor.display_name.to_string(), + description: descriptor.description.to_string(), + active_clients: channel.active_client_count(), + queue_length, + update_id, + last_change, + history_entries: history_len, + history_max_tracks: config.history.max_tracks, + configured, + cache_collection_id: cache_stats.collection_id, + cache_total_tracks: cache_stats.total_tracks, + cache_cached_tracks: cache_stats.cached_tracks, + }; + + Ok(Json(status)) +} + +/// GET /channels/{channel_id}/playlist - File d'attente du canal +#[utoipa::path( + get, + path = "/channels/{channel_id}/playlist", + params( + ("channel_id" = u8, Path, description = "Channel ID (0-3)"), + ListLimitQuery + ), + responses( + (status = 200, description = "File d'attente courante", body = ChannelPlaylistResponse), + (status = 400, description = "Canal invalide"), + (status = 503, description = "Canal indisponible"), + (status = 500, description = "Erreur lors de la rĂ©cupĂ©ration de la file d'attente") + ), + tag = "Radio Paradise" +)] +async fn get_channel_playlist( + State(state): State, + Path(channel_id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let channel = state.channel_for_id(channel_id)?; + let descriptor = channel.descriptor(); + let playlist = channel.playlist(); + let snapshot = playlist.active_snapshot().await; + let total_len = snapshot.len(); + let limit = query.limit.filter(|limit| *limit > 0).unwrap_or(total_len); + + let cache_manager = channel.cache_manager(); + let mut items = Vec::new(); + + for (index, entry) in snapshot.into_iter().enumerate().take(limit) { + let cache_status = match cache_manager.get_cache_status(&entry.track_id).await { + Ok(status) => status, + Err(err) => CacheStatus::Failed { + error: err.to_string(), + }, + }; + + items.push(ChannelPlaylistEntry::from_entry( + &entry, + index, + CacheStatusInfo::from(cache_status), + )); + } + + let response = ChannelPlaylistResponse { + channel_id, + slug: descriptor.slug.to_string(), + update_id: playlist.update_id(), + queue_length: total_len, + items, + }; + + Ok(Json(response)) +} + +/// GET /channels/{channel_id}/history - Historique rĂ©cent du canal +#[utoipa::path( + get, + path = "/channels/{channel_id}/history", + params( + ("channel_id" = u8, Path, description = "Channel ID (0-3)"), + ListLimitQuery + ), + responses( + (status = 200, description = "Historique rĂ©cent", body = ChannelHistoryResponse), + (status = 400, description = "Canal invalide"), + (status = 503, description = "Canal indisponible"), + (status = 500, description = "Erreur lors de la rĂ©cupĂ©ration de l'historique") + ), + tag = "Radio Paradise" +)] +async fn get_channel_history( + State(state): State, + Path(channel_id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let channel = state.channel_for_id(channel_id)?; + let descriptor = channel.descriptor(); + let backend = channel.history_backend().clone(); + let limit = query.limit.unwrap_or(50); + + let entries_raw = backend.recent(limit).await.map_err(|e| { + error!( + channel = descriptor.slug, + "Failed to retrieve channel history: {e:?}" + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let total_available = backend.len().await.map_err(|e| { + error!( + channel = descriptor.slug, + "Failed to count channel history entries: {e:?}" + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let entries: Vec = entries_raw + .into_iter() + .map(|entry| ChannelHistoryEntry { + track_id: entry.track_id, + channel_id: entry.channel_id, + title: entry.song.title, + artist: entry.song.artist, + album: entry.song.album, + cover_url: entry.song.cover_url, + started_at: entry.started_at.to_rfc3339(), + duration_ms: entry.duration_ms, + }) + .collect(); + + let response = ChannelHistoryResponse { + channel_id, + slug: descriptor.slug.to_string(), + total_available, + returned: entries.len(), + entries, + }; + + Ok(Json(response)) +} + +/// GET /stream - Stream audio pour un canal donnĂ© +#[utoipa::path( + get, + path = "/stream", + params( + ("channel" = Option, Query, description = "Channel ID (0-3)"), + ("client_id" = Option, Query, description = "Identifiant personnalisĂ© du client") + ), + responses( + (status = 200, description = "Flux audio FLAC (gapless)", content_type = "audio/flac"), + (status = 400, description = "Paramètres invalides"), + (status = 503, description = "Canal indisponible") + ), + tag = "Radio Paradise" +)] +async fn stream_channel( + State(state): State, + Query(params): Query, +) -> Result { + let channel_id = params.channel.unwrap_or(0); + let channel = state.channel_for_id(channel_id)?; + + let client_id = params.client_id.clone().unwrap_or_else(|| { + format!( + "api-{}-{}", + channel.descriptor().slug, + Utc::now().timestamp_micros() + ) + }); + + let client_stream = channel.connect_client(client_id).await.map_err(|e| { + error!("Failed to create streaming client: {e:?}"); + StatusCode::SERVICE_UNAVAILABLE + })?; + + let stream = client_stream + .into_byte_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); + + let body = Body::from_stream(stream); + + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static("audio/flac"), + ); + headers.insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + ); + headers.insert( + HeaderName::from_static("icy-name"), + HeaderValue::from_static("Radio Paradise"), + ); + headers.insert( + HeaderName::from_static("icy-genre"), + HeaderValue::from_static("Eclectic"), + ); + headers.insert( + HeaderName::from_static("icy-description"), + HeaderValue::from_static("PMO Radio Paradise relay"), + ); + headers.insert( + HeaderName::from_static("icy-metaint"), + HeaderValue::from_static("0"), + ); + + Ok((headers, body)) +} + /// Documentation OpenAPI pour l'API Radio Paradise #[derive(OpenApi)] #[openapi( @@ -394,14 +851,24 @@ async fn get_bitrates() -> Json> { get_current_block, get_block_by_id, get_channels, - get_bitrates + get_channel_status, + get_channel_playlist, + get_channel_history, + get_bitrates, + stream_channel ), components(schemas( NowPlayingResponse, BlockResponse, SongInfo, ChannelInfo, - BitrateInfo + BitrateInfo, + ChannelStatusResponse, + ChannelPlaylistEntry, + ChannelPlaylistResponse, + ChannelHistoryEntry, + ChannelHistoryResponse, + CacheStatusInfo )), tags( (name = "Radio Paradise", description = "Endpoints pour Radio Paradise streaming") @@ -416,7 +883,11 @@ pub fn create_api_router(state: RadioParadiseState) -> Router { .route("/block/current", get(get_current_block)) .route("/block/{event_id}", get(get_block_by_id)) .route("/channels", get(get_channels)) + .route("/channels/{channel_id}/status", get(get_channel_status)) + .route("/channels/{channel_id}/playlist", get(get_channel_playlist)) + .route("/channels/{channel_id}/history", get(get_channel_history)) .route("/bitrates", get(get_bitrates)) + .route("/stream", get(stream_channel)) .with_state(state) } @@ -430,6 +901,9 @@ pub trait RadioParadiseExt { /// # Routes créées /// /// - API: `/api/radioparadise/*` + /// - `/now-playing` + /// - `/block/*` + /// - `/stream` /// - Swagger: `/swagger-ui/radioparadise` async fn init_radioparadise(&mut self) -> anyhow::Result; } diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 33abe967..7b6f00f9 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -1,60 +1,32 @@ -//! Music source implementation for Radio Paradise +//! Music source implementation for Radio Paradise built on the new +//! `paradise` orchestration layer. //! -//! This module implements the [`pmosource::MusicSource`] trait for Radio Paradise, -//! providing a complete music source with FIFO playlist support, browsing, and caching. +//! The source exposes a DIDL-Lite hierarchy compatible with UPnP +//! ContentDirectory while delegating block ingestion, caching and +//! multi-client streaming to [`ParadiseChannel`]. use crate::client::RadioParadiseClient; -use crate::models::{Block, Song}; -use anyhow::anyhow; +use crate::paradise::{ + history_backend_from_config, ChannelDescriptor, MemoryHistoryBackend, ParadiseChannel, + PlaylistEntry, RadioParadiseConfig, ALL_CHANNELS, +}; +use anyhow::Result as AnyhowResult; use pmoaudiocache::Cache as AudioCache; use pmocovers::Cache as CoverCache; -use pmodidl::{Container, Item}; -use pmoplaylist::{FifoPlaylist, Track}; -use pmosource::SourceCacheManager; -use pmosource::{async_trait, pmodidl, BrowseResult, MusicSource, MusicSourceError, Result}; -use std::collections::{HashMap, HashSet}; -use std::io::Cursor; +use pmodidl::{Container, Item, Resource}; +use pmosource::pmodidl; +use pmosource::{ + async_trait, BrowseResult, CacheStatus, MusicSource, MusicSourceError, Result, + SourceCacheManager, SourceStatistics, +}; +use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; -use tokio::sync::{Mutex, RwLock}; -use url::Url; +use tracing::warn; /// Default image for Radio Paradise (300x300 WebP, embedded in binary) const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); -/// Default FIFO capacity (number of recent tracks to keep) -const DEFAULT_FIFO_CAPACITY: usize = 50; - -#[derive(Clone, Copy)] -struct ChannelDescriptor { - id: u8, - name: &'static str, - description: &'static str, -} - -const CHANNELS: [ChannelDescriptor; 4] = [ - ChannelDescriptor { - id: 0, - name: "Main Mix", - description: "Eclectic mix of rock, world, electronica, and more", - }, - ChannelDescriptor { - id: 1, - name: "Mellow Mix", - description: "Mellower, less aggressive music", - }, - ChannelDescriptor { - id: 2, - name: "Rock Mix", - description: "Heavier, more guitar-driven music", - }, - ChannelDescriptor { - id: 3, - name: "World Mix", - description: "Global beats and world music", - }, -]; - fn channel_collection_id(channel_id: u8) -> String { format!("radio-paradise:{}", channel_id) } @@ -63,10 +35,6 @@ fn channel_container_id(channel_id: u8) -> String { format!("radio-paradise:channel:{}", channel_id) } -fn channel_playlist_id(channel_id: u8) -> String { - channel_container_id(channel_id) -} - fn parse_channel_container_id(object_id: &str) -> Option { let mut parts = object_id.split(':'); match (parts.next(), parts.next(), parts.next(), parts.next()) { @@ -75,129 +43,28 @@ fn parse_channel_container_id(object_id: &str) -> Option { } } -fn track_identifier(channel_id: u8, event: u64, song_index: usize) -> String { - format!("rp:{}:{}:{}", channel_id, event, song_index) -} - -fn parse_track_identifier(track_id: &str) -> Option<(u8, u64, usize)> { +fn parse_track_channel(track_id: &str) -> Option { let mut parts = track_id.split(':'); - match ( - parts.next(), - parts.next(), - parts.next(), - parts.next(), - parts.next(), - ) { - (Some("rp"), Some(channel_str), Some(event_str), Some(index_str), None) => { - let channel = channel_str.parse().ok()?; - let event = event_str.parse().ok()?; - let idx = index_str.parse().ok()?; - Some((channel, event, idx)) - } + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some("rp"), Some(channel_str), Some(_rest), None) => channel_str.parse().ok(), _ => None, } } -fn resolve_cover_url( - image_base: Option<&str>, - client: &RadioParadiseClient, - cover: &str, -) -> anyhow::Result { - if cover.starts_with("http://") || cover.starts_with("https://") { - return Url::parse(cover).map_err(|e| anyhow!("Invalid cover URL '{}': {}", cover, e)); - } - - if cover.starts_with("//") { - let url = format!("https:{}", cover); - return Url::parse(&url).map_err(|e| anyhow!("Invalid cover URL '{}': {}", cover, e)); - } - - if let Some(base) = image_base { - match Url::parse(base).and_then(|base_url| base_url.join(cover)) { - Ok(url) => return Ok(url), - Err(err) => { - tracing::debug!( - "Failed to join cover '{}' with image base '{}': {}", - cover, - base, - err - ); - } - } - } - - client - .cover_url(cover) - .map_err(|e| anyhow!("Invalid cover URL '{}': {}", cover, e)) +fn format_duration(duration_seconds: u64) -> String { + let hours = duration_seconds / 3600; + let minutes = (duration_seconds % 3600) / 60; + let seconds = duration_seconds % 60; + format!("{hours}:{minutes:02}:{seconds:02}") } -/// Radio Paradise music source with full MusicSource trait implementation -/// -/// This struct combines a [`RadioParadiseClient`] for API access with a FIFO playlist -/// for dynamic track management, implementing the complete [`MusicSource`] trait. -/// -/// # Features -/// -/// - **FIFO Playlist**: Dynamic track management with configurable capacity -/// - **API Integration**: Fetches blocks and metadata from Radio Paradise -/// - **URI Resolution**: Resolves track URIs with optional cache support -/// - **Change Tracking**: Tracks update_id and last_change for UPnP notifications -/// - **DIDL-Lite Export**: Converts tracks and blocks to UPnP-compatible formats -/// -/// # Examples -/// -/// ```no_run -/// use pmoparadise::{RadioParadiseClient, RadioParadiseSource}; -/// use pmosource::MusicSource; -/// use std::sync::Arc; -/// -/// #[tokio::main] -/// async fn main() -> Result<(), Box> { -/// let client = RadioParadiseClient::new().await?; -/// -/// let base_dir = std::env::temp_dir().join("pmoparadise_doc_source"); -/// let cover_dir = base_dir.join("covers"); -/// let audio_dir = base_dir.join("audio"); -/// std::fs::create_dir_all(&cover_dir)?; -/// std::fs::create_dir_all(&audio_dir)?; -/// -/// let cover_dir_str = cover_dir.to_string_lossy().into_owned(); -/// let audio_dir_str = audio_dir.to_string_lossy().into_owned(); -/// let cover_cache = Arc::new(pmocovers::cache::new_cache(&cover_dir_str, 32)?); -/// let audio_cache = Arc::new(pmoaudiocache::cache::new_cache(&audio_dir_str, 32)?); -/// -/// let source = RadioParadiseSource::new(client, 50, cover_cache, audio_cache); -/// -/// println!("Source: {}", source.name()); -/// println!("Supports FIFO: {}", source.supports_fifo()); -/// -/// // Start streaming and the FIFO will be populated -/// Ok(()) -/// } -/// ``` #[derive(Clone)] pub struct RadioParadiseSource { inner: Arc, } -struct ChannelState { - descriptor: ChannelDescriptor, - client: RadioParadiseClient, - playlist: FifoPlaylist, - cache_manager: SourceCacheManager, - processed_blocks: RwLock>, - ingest_lock: Mutex<()>, -} - -struct DecodedBlock { - samples: Vec, - channels: usize, - sample_rate: u32, - bits_per_sample: u32, -} - struct RadioParadiseSourceInner { - channels: HashMap>, + channels: HashMap>, } impl std::fmt::Debug for RadioParadiseSource { @@ -207,47 +74,37 @@ impl std::fmt::Debug for RadioParadiseSource { } impl RadioParadiseSource { - /// Create a new Radio Paradise source from the cache registry - /// - /// This is the recommended way to create a source when using the UPnP server. - /// The caches are automatically retrieved from the global registry. - /// - /// # Arguments - /// - /// * `client` - Radio Paradise API client - /// * `fifo_capacity` - Maximum number of tracks in the FIFO - /// - /// # Errors - /// - /// Returns an error if the caches are not initialized in the registry #[cfg(feature = "server")] - pub fn from_registry(client: RadioParadiseClient, fifo_capacity: usize) -> Result { + pub fn from_registry(client: RadioParadiseClient, _legacy_capacity: usize) -> Result { + let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default()); + let history_backend = history_backend_from_config(&config.history).map_err(|e| { + MusicSourceError::SourceUnavailable(format!( + "Failed to initialize history backend: {}", + e + )) + })?; let mut channels = HashMap::new(); - for descriptor in CHANNELS.iter() { - let channel_id = descriptor.id; - let channel_client = client.clone_with_channel(channel_id); - let playlist = FifoPlaylist::new( - channel_playlist_id(channel_id), - descriptor.name.to_string(), - fifo_capacity, - DEFAULT_IMAGE, - ); - - let cache_manager = - SourceCacheManager::from_registry(channel_collection_id(channel_id))?; - - channels.insert( - channel_id, - Arc::new(ChannelState { - descriptor: *descriptor, - client: channel_client, - playlist, + for descriptor in ALL_CHANNELS.iter() { + let cache_manager = Arc::new(SourceCacheManager::from_registry( + channel_collection_id(descriptor.id), + )?); + let channel = Arc::new( + ParadiseChannel::new( + *descriptor, + client.clone(), + config.clone(), + history_backend.clone(), cache_manager, - processed_blocks: RwLock::new(HashSet::new()), - ingest_lock: Mutex::new(()), - }), + ) + .map_err(|e| { + MusicSourceError::SourceUnavailable(format!( + "Failed to initialize channel {}: {e}", + descriptor.slug + )) + })?, ); + channels.insert(descriptor.id, channel); } Ok(Self { @@ -255,55 +112,48 @@ impl RadioParadiseSource { }) } - /// Create with default FIFO capacity from the cache registry #[cfg(feature = "server")] pub fn from_registry_default(client: RadioParadiseClient) -> Result { - Self::from_registry(client, DEFAULT_FIFO_CAPACITY) + Self::from_registry(client, 0) } - /// Create a new Radio Paradise source with explicit caches (for tests) - /// - /// # Arguments - /// - /// * `client` - Radio Paradise API client - /// * `fifo_capacity` - Maximum number of tracks in the FIFO - /// * `cover_cache` - Cover image cache (required) - /// * `audio_cache` - Audio cache (required) pub fn new( client: RadioParadiseClient, - fifo_capacity: usize, + _legacy_capacity: usize, cover_cache: Arc, audio_cache: Arc, ) -> Self { + let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default()); + let history_backend: Arc = + history_backend_from_config(&config.history).unwrap_or_else(|err| { + warn!("Falling back to in-memory history backend: {err}"); + Arc::new(MemoryHistoryBackend::new()) as Arc + }); let mut channels = HashMap::new(); - for descriptor in CHANNELS.iter() { - let channel_id = descriptor.id; - let channel_client = client.clone_with_channel(channel_id); - let playlist = FifoPlaylist::new( - channel_playlist_id(channel_id), - descriptor.name.to_string(), - fifo_capacity, - DEFAULT_IMAGE, - ); - - let cache_manager = SourceCacheManager::new( - channel_collection_id(channel_id), + for descriptor in ALL_CHANNELS.iter() { + let cache_manager = Arc::new(SourceCacheManager::new( + channel_collection_id(descriptor.id), Arc::clone(&cover_cache), Arc::clone(&audio_cache), - ); - - channels.insert( - channel_id, - Arc::new(ChannelState { - descriptor: *descriptor, - client: channel_client, - playlist, - cache_manager, - processed_blocks: RwLock::new(HashSet::new()), - ingest_lock: Mutex::new(()), - }), - ); + )); + match ParadiseChannel::new( + *descriptor, + client.clone(), + config.clone(), + history_backend.clone(), + cache_manager, + ) { + Ok(channel) => { + channels.insert(descriptor.id, Arc::new(channel)); + } + Err(err) => { + warn!( + channel = descriptor.slug, + "Failed to initialize channel: {err:?}" + ); + } + } } Self { @@ -311,25 +161,23 @@ impl RadioParadiseSource { } } - /// Create with default FIFO capacity (for tests) pub fn new_default( client: RadioParadiseClient, cover_cache: Arc, audio_cache: Arc, ) -> Self { - Self::new(client, DEFAULT_FIFO_CAPACITY, cover_cache, audio_cache) + Self::new(client, 0, cover_cache, audio_cache) } - /// Get the Radio Paradise client for a given channel pub fn client_for_channel(&self, channel: u8) -> Option { self.inner .channels .get(&channel) - .map(|state| state.client.clone()) + .map(|ch| ch.client().clone()) } - fn channel_state(&self, channel_id: u8) -> Option> { - self.inner.channels.get(&channel_id).cloned() + pub fn channel(&self, id: u8) -> Option> { + self.inner.channels.get(&id).cloned() } fn build_root_container(&self) -> Container { @@ -337,7 +185,7 @@ impl RadioParadiseSource { id: "radio-paradise".to_string(), parent_id: "0".to_string(), restricted: Some("1".to_string()), - child_count: Some(CHANNELS.len().to_string()), + child_count: Some(ALL_CHANNELS.len().to_string()), title: "Radio Paradise".to_string(), class: "object.container".to_string(), containers: vec![], @@ -347,15 +195,15 @@ impl RadioParadiseSource { async fn build_channel_containers(&self) -> Vec { let mut containers = Vec::new(); - for descriptor in CHANNELS.iter() { - if let Some(channel) = self.channel_state(descriptor.id) { - let child_count = channel.playlist.len().await; + for descriptor in ALL_CHANNELS.iter() { + if let Some(channel) = self.channel(descriptor.id) { + let len = channel.playlist().active_len().await; containers.push(Container { id: channel_container_id(descriptor.id), parent_id: "radio-paradise".to_string(), restricted: Some("1".to_string()), - child_count: Some(child_count.to_string()), - title: descriptor.name.to_string(), + child_count: Some(len.to_string()), + title: descriptor.display_name.to_string(), class: "object.container.playlistContainer".to_string(), containers: vec![], items: vec![], @@ -365,503 +213,112 @@ impl RadioParadiseSource { containers } - async fn ensure_channel_ready(&self, channel: Arc) -> Result<()> { - if channel.playlist.len().await > 0 { - return Ok(()); - } - - let guard = channel.ingest_lock.lock().await; - if channel.playlist.len().await == 0 { - drop(guard); - self.populate_channel_locked(channel.clone()).await?; - } else { - drop(guard); - } - - Ok(()) - } - - async fn prepare_initial_track( + async fn channel_items( &self, - channel: Arc, - block: Arc, - ) -> Result<()> { - let ordered_songs = block.songs_ordered(); - let (song_index, song) = match ordered_songs.first() { - Some(entry) => entry, - None => return Ok(()), - }; - - let track_id = track_identifier(channel.descriptor.id, block.event, *song_index); - - if channel.playlist.has_track(&track_id).await { - return Ok(()); - } - - let placeholder_uri = format!("{}#{}", block.url, *song_index); + descriptor: ChannelDescriptor, + offset: usize, + limit: Option, + ) -> Result> { + let channel = self + .channel(descriptor.id) + .ok_or_else(|| MusicSourceError::ObjectNotFound(descriptor.slug.to_string()))?; channel - .cache_manager - .update_metadata( - track_id.clone(), - pmosource::TrackMetadata { - original_uri: placeholder_uri.clone(), - cached_audio_pk: None, - cached_cover_pk: None, - }, - ) - .await; - - let mut track = Track::new( - track_id.clone(), - song.title.clone(), - placeholder_uri.clone(), - ); - - if !song.artist.is_empty() { - track = track.with_artist(song.artist.clone()); - } - - if let Some(ref album) = song.album { - if !album.is_empty() { - track = track.with_album(album.clone()); - } - } - - let duration_ms = song_duration_ms(&block, &ordered_songs, 0); - if duration_ms > 0 { - track = track.with_duration((duration_ms / 1000) as u32); - } - - if let Some(ref cover) = song.cover { - if let Ok(url) = resolve_cover_url(block.image_base.as_deref(), &channel.client, cover) - { - track = track.with_image(url.to_string()); - } - } - - channel.playlist.append_track(track).await; - - Ok(()) - } - - async fn populate_channel_locked(&self, channel: Arc) -> Result<()> { - tracing::info!( - "đź“» Fetching Radio Paradise block for channel {}", - channel.descriptor.name - ); - - let now_playing = channel - .client - .now_playing() + .ensure_started() .await .map_err(|e| MusicSourceError::SourceUnavailable(e.to_string()))?; - let block = Arc::new(now_playing.block); - self.prepare_initial_track(channel.clone(), block.clone()) - .await?; - - let source_clone = self.clone(); - tokio::spawn(async move { - if let Err(e) = source_clone - .ingest_block(channel.clone(), block.clone()) - .await - { - tracing::error!( - "Failed to ingest block {} on channel {}: {}", - block.event, - channel.descriptor.name, - e - ); - } - }); - - Ok(()) - } - - async fn ingest_block(&self, channel: Arc, block: Arc) -> Result<()> { - { - let mut processed = channel.processed_blocks.write().await; - if !processed.insert(block.event) { - tracing::debug!( - "Channel {} already processed block {}", - channel.descriptor.name, - block.event - ); - return Ok(()); - } + let entries = channel.playlist().active_snapshot().await; + if entries.is_empty() || offset >= entries.len() { + return Ok(Vec::new()); } - let block_url = Url::parse(&block.url) - .map_err(|e| MusicSourceError::BrowseError(format!("Invalid block URL: {}", e)))?; + let end = limit + .map(|count| offset + count) + .unwrap_or(entries.len()) + .min(entries.len()); - let block_bytes = channel - .client - .download_block(&block_url) + let parent_id = channel_container_id(descriptor.id); + + let mut items = Vec::with_capacity(end - offset); + for entry in entries.into_iter().skip(offset).take(end - offset) { + match self.entry_to_item(channel.clone(), &parent_id, entry).await { + Ok(item) => items.push(item), + Err(err) => warn!( + channel = descriptor.slug, + "Failed to build DIDL item: {err:?}" + ), + } + } + Ok(items) + } + + async fn entry_to_item( + &self, + channel: Arc, + parent_id: &str, + entry: Arc, + ) -> AnyhowResult { + let cache_manager = channel.cache_manager(); + let metadata = cache_manager.get_metadata(&entry.track_id).await; + + let resource_url = cache_manager + .resolve_uri(&entry.track_id) .await - .map_err(|e| { - MusicSourceError::BrowseError(format!("Failed to download block: {}", e)) + .or_else(|_| { + metadata + .as_ref() + .map(|meta| meta.original_uri.clone()) + .ok_or_else(|| MusicSourceError::ObjectNotFound(entry.track_id.clone())) })?; - let decoded = decode_block_audio(block_bytes.to_vec()) - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let mut album_art = metadata + .as_ref() + .and_then(|meta| meta.cached_cover_pk.as_ref()) + .and_then(|pk| cache_manager.cover_url(pk, None).ok()); - let ordered_songs = block.songs_ordered(); - let total_frames = decoded.samples.len() / decoded.channels; - - for (position, (song_index, song)) in ordered_songs.iter().enumerate() { - let track_id = track_identifier(channel.descriptor.id, block.event, *song_index); - let placeholder_uri = format!("{}#{}", block.url, *song_index); - - let existing_metadata = channel.cache_manager.get_metadata(&track_id).await; - if let Some(ref metadata) = existing_metadata { - if metadata.cached_audio_pk.is_some() { - continue; - } - } else { - channel - .cache_manager - .update_metadata( - track_id.clone(), - pmosource::TrackMetadata { - original_uri: placeholder_uri.clone(), - cached_audio_pk: None, - cached_cover_pk: None, - }, - ) - .await; - } - - let duration_ms = song_duration_ms(&block, &ordered_songs, position); - if duration_ms == 0 { - tracing::debug!( - "Skipping track {} with zero duration on channel {}", - track_id, - channel.descriptor.name - ); - continue; - } - - let start_frame = ms_to_frames(song.elapsed, decoded.sample_rate); - let end_frame = - ms_to_frames(song.elapsed + duration_ms, decoded.sample_rate).min(total_frames); - - if start_frame >= end_frame { - tracing::debug!( - "Invalid frame range for track {} (start {} >= end {})", - track_id, - start_frame, - end_frame - ); - continue; - } - - let start_index = start_frame * decoded.channels; - let end_index = end_frame * decoded.channels; - let song_samples = decoded.samples[start_index..end_index].to_vec(); - - let flac_data = encode_samples_to_flac( - song_samples, - decoded.channels, - decoded.sample_rate, - decoded.bits_per_sample, - ) - .await - .map_err(|e| MusicSourceError::CacheError(e.to_string()))?; - - let audio_source_uri = format!("{}#{}", block.url, song_index); - let data_len = flac_data.len() as u64; - let reader = Cursor::new(flac_data); - let audio_pk: String = channel - .cache_manager - .cache_audio_from_reader(&audio_source_uri, reader, Some(data_len)) - .await?; - - let resolved_cover_url = - song.cover.as_ref().and_then(|cover| { - match resolve_cover_url(block.image_base.as_deref(), &channel.client, cover) { - Ok(url) => Some(url.to_string()), - Err(e) => { - tracing::warn!( - "Failed to resolve cover '{}' for channel {}: {}", - cover, - channel.descriptor.name, - e - ); - None - } - } - }); - - let cached_cover_pk = if let Some(ref cover_url) = resolved_cover_url { - match channel.cache_manager.cache_cover(cover_url).await { - Ok(pk) => Some(pk), - Err(e) => { - tracing::warn!( - "Failed to cache cover {} on channel {}: {}", - cover_url, - channel.descriptor.name, - e - ); - None - } - } - } else { - None - }; - - let metadata_cover_pk = cached_cover_pk.clone(); - channel - .cache_manager - .update_metadata( - track_id.clone(), - pmosource::TrackMetadata { - original_uri: existing_metadata - .and_then(|m| { - if m.original_uri.is_empty() { - None - } else { - Some(m.original_uri) - } - }) - .unwrap_or_else(|| placeholder_uri.clone()), - cached_audio_pk: Some(audio_pk.clone()), - cached_cover_pk: metadata_cover_pk, - }, - ) - .await; - - let playback_url = channel.cache_manager.resolve_uri(&track_id).await?; - - let mut track = Track::new(track_id.clone(), song.title.clone(), playback_url); - - if !song.artist.is_empty() { - track = track.with_artist(song.artist.clone()); - } - - if let Some(ref album) = song.album { - if !album.is_empty() { - track = track.with_album(album.clone()); - } - } - - track = track.with_duration((duration_ms / 1000) as u32); - - if let Some(ref cover_pk) = cached_cover_pk { - if let Ok(url) = channel.cache_manager.cover_url(cover_pk, None) { - track = track.with_image(url); - } - } else if let Some(ref cover_url) = resolved_cover_url { - track = track.with_image(cover_url.clone()); - } - - let updated = channel - .playlist - .update_track(&track_id, |existing| { - existing.title = track.title.clone(); - existing.artist = track.artist.clone(); - existing.album = track.album.clone(); - existing.duration = track.duration; - existing.uri = track.uri.clone(); - existing.image = track.image.clone(); - }) - .await; - - if !updated { - channel.playlist.append_track(track).await; - } - - let channel_for_wait = channel.clone(); - let track_id_for_wait = track_id.clone(); - let audio_pk_for_wait = audio_pk.clone(); - tokio::spawn(async move { - if let Err(e) = channel_for_wait - .cache_manager - .wait_audio_ready(&audio_pk_for_wait) - .await - { - tracing::error!( - "Failed to finalize audio {} on channel {}: {}", - track_id_for_wait, - channel_for_wait.descriptor.name, - e - ); - channel_for_wait - .cache_manager - .remove_track(&track_id_for_wait) - .await; - channel_for_wait - .playlist - .remove_by_id(&track_id_for_wait) - .await; - } - }); + if album_art.is_none() { + album_art = entry.song.cover.clone(); } - tracing::info!( - "Channel {} now has {} tracks", - channel.descriptor.name, - channel.playlist.len().await - ); - - Ok(()) - } -} - -fn song_duration_ms(block: &Block, ordered: &[(usize, &Song)], position: usize) -> u64 { - let song = ordered[position].1; - if song.duration > 0 { - return song.duration; - } - - if let Some((_, next_song)) = ordered.get(position + 1) { - return next_song.elapsed.saturating_sub(song.elapsed); - } - - block.length.saturating_sub(song.elapsed) -} - -fn ms_to_frames(ms: u64, sample_rate: u32) -> usize { - ((ms as u128 * sample_rate as u128) / 1000) as usize -} - -fn decode_block_audio(data: Vec) -> anyhow::Result { - use symphonia::core::audio::SampleBuffer; - use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; - use symphonia::core::errors::Error as SymphoniaError; - use symphonia::core::formats::FormatOptions; - use symphonia::core::io::MediaSourceStream; - use symphonia::core::meta::MetadataOptions; - use symphonia::core::probe::Hint; - - let cursor = Cursor::new(data); - let mss = MediaSourceStream::new(Box::new(cursor), Default::default()); - - let hint = Hint::new(); - let probed = symphonia::default::get_probe() - .format( - &hint, - mss, - &FormatOptions::default(), - &MetadataOptions::default(), - ) - .map_err(|e| anyhow!("Failed to probe format: {}", e))?; - - let mut format = probed.format; - - let track = format - .tracks() - .iter() - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) - .ok_or_else(|| anyhow!("No audio track found"))?; - - let mut decoder = symphonia::default::get_codecs() - .make(&track.codec_params, &DecoderOptions::default()) - .map_err(|e| anyhow!("Failed to create decoder: {}", e))?; - - let channels = track - .codec_params - .channels - .ok_or_else(|| anyhow!("Missing channel info"))? - .count(); - - let sample_rate = track - .codec_params - .sample_rate - .ok_or_else(|| anyhow!("Missing sample rate"))?; - - let bits_per_sample = track.codec_params.bits_per_sample.unwrap_or(16); - - let mut samples_i32 = Vec::new(); - let track_id = track.id; - - loop { - let packet = match format.next_packet() { - Ok(packet) => packet, - Err(SymphoniaError::ResetRequired) => { - decoder.reset(); - continue; - } - Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { - break; - } - Err(e) => return Err(anyhow!("Decode error: {}", e)), + let duration_seconds = entry.duration_ms / 1000; + let duration_str = if duration_seconds > 0 { + Some(format_duration(duration_seconds as u64)) + } else { + None }; - if packet.track_id() != track_id { - continue; - } + let resource = Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: duration_str.clone(), + url: resource_url, + }; - match decoder.decode(&packet) { - Ok(decoded) => { - let spec = *decoded.spec(); - let duration = decoded.capacity() as u64; - let mut sample_buf = SampleBuffer::::new(duration, spec); - sample_buf.copy_interleaved_ref(decoded); - samples_i32.extend_from_slice(sample_buf.samples()); - } - Err(SymphoniaError::DecodeError(_)) => continue, - Err(e) => return Err(anyhow!("Decode error: {}", e)), - } + Ok(Item { + id: entry.track_id.clone(), + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + title: entry.song.title.clone(), + creator: Some(entry.song.artist.clone()), + class: "object.item.audioItem.musicTrack".to_string(), + artist: Some(entry.song.artist.clone()), + album: entry.song.album.clone(), + genre: None, + album_art, + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![resource], + descriptions: vec![], + }) } - if samples_i32.is_empty() { - return Err(anyhow!("No samples decoded")); + fn channels_iter(&self) -> impl Iterator)> { + self.inner.channels.iter() } - - let (normalized_samples, target_bits): (Vec, u32) = match bits_per_sample { - 0..=16 => { - let samples = samples_i32.iter().map(|&s| (s >> 16) as i32).collect(); - (samples, 16) - } - 17..=24 => { - let samples = samples_i32.iter().map(|&s| (s >> 8) as i32).collect(); - (samples, 24) - } - _ => (samples_i32, 32), - }; - - Ok(DecodedBlock { - samples: normalized_samples, - channels, - sample_rate, - bits_per_sample: target_bits, - }) -} - -async fn encode_samples_to_flac( - samples: Vec, - channels: usize, - sample_rate: u32, - bits_per_sample: u32, -) -> anyhow::Result> { - tokio::task::spawn_blocking(move || { - use flacenc::bitsink::ByteSink; - use flacenc::component::BitRepr; - use flacenc::error::Verify; - - let config = flacenc::config::Encoder::default() - .into_verified() - .map_err(|e| anyhow!("FLAC config error: {:?}", e))?; - - let source = flacenc::source::MemSource::from_samples( - &samples, - channels, - bits_per_sample as usize, - sample_rate as usize, - ); - - let flac_stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size) - .map_err(|e| anyhow!("FLAC encode error: {:?}", e))?; - - let mut sink = ByteSink::new(); - flac_stream - .write(&mut sink) - .map_err(|e| anyhow!("FLAC write error: {:?}", e))?; - - Ok::<_, anyhow::Error>(sink.into_inner()) - }) - .await? } #[async_trait] @@ -891,12 +348,12 @@ impl MusicSource for RadioParadiseSource { } _ => { if let Some(channel_id) = parse_channel_container_id(object_id) { - let channel = self - .channel_state(channel_id) + let descriptor = ALL_CHANNELS + .iter() + .find(|desc| desc.id == channel_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; - self.ensure_channel_ready(channel.clone()).await?; - let len = channel.playlist.len().await; - let items = channel.playlist.as_objects(0, len, None).await; + + let items = self.channel_items(*descriptor, 0, None).await?; Ok(BrowseResult::Items(items)) } else { Err(MusicSourceError::ObjectNotFound(object_id.to_string())) @@ -906,12 +363,16 @@ impl MusicSource for RadioParadiseSource { } async fn resolve_uri(&self, object_id: &str) -> Result { - let (channel_id, _, _) = parse_track_identifier(object_id) + let channel_id = parse_track_channel(object_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; let channel = self - .channel_state(channel_id) + .channel(channel_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; - channel.cache_manager.resolve_uri(object_id).await + channel + .cache_manager() + .resolve_uri(object_id) + .await + .map_err(|e| MusicSourceError::CacheError(e.to_string())) } fn supports_fifo(&self) -> bool { @@ -927,21 +388,16 @@ impl MusicSource for RadioParadiseSource { } async fn update_id(&self) -> u32 { - let mut max_id = 0; - for descriptor in CHANNELS.iter() { - if let Some(channel) = self.channel_state(descriptor.id) { - let id = channel.playlist.update_id().await; - max_id = max_id.max(id); - } - } - max_id + self.channels_iter() + .map(|(_, channel)| channel.playlist().update_id()) + .max() + .unwrap_or(0) } async fn last_change(&self) -> Option { let mut latest: Option = None; - for descriptor in CHANNELS.iter() { - if let Some(channel) = self.channel_state(descriptor.id) { - let change = channel.playlist.last_change().await; + for (_, channel) in self.channels_iter() { + if let Some(change) = channel.playlist().last_change().await { latest = Some(match latest { Some(current) if change <= current => current, _ => change, @@ -952,117 +408,60 @@ impl MusicSource for RadioParadiseSource { } async fn get_items(&self, offset: usize, count: usize) -> Result> { - let mut all_items = Vec::new(); - for descriptor in CHANNELS.iter() { - if let Some(channel) = self.channel_state(descriptor.id) { - self.ensure_channel_ready(channel.clone()).await?; - let len = channel.playlist.len().await; - let mut items = channel.playlist.as_objects(0, len, None).await; - all_items.append(&mut items); - } + let mut all = Vec::new(); + for descriptor in ALL_CHANNELS.iter() { + let mut items = self.channel_items(*descriptor, 0, None).await?; + all.append(&mut items); } - let total = all_items.len(); - if offset >= total { + if offset >= all.len() { return Ok(Vec::new()); } let end = if count == 0 { - total + all.len() } else { - (offset + count).min(total) + (offset + count).min(all.len()) }; - Ok(all_items - .into_iter() - .skip(offset) - .take(end - offset) - .collect()) - } - - async fn search(&self, _query: &str) -> Result { - Err(MusicSourceError::SearchNotSupported) - } - - fn capabilities(&self) -> pmosource::SourceCapabilities { - pmosource::SourceCapabilities { - supports_fifo: false, - supports_search: false, - supports_favorites: false, - supports_playlists: false, - supports_user_content: false, - supports_high_res_audio: true, - max_sample_rate: Some(96_000), - supports_multiple_formats: true, - supports_advanced_search: false, - supports_pagination: true, - } + Ok(all.into_iter().skip(offset).take(end - offset).collect()) } async fn get_available_formats(&self, _object_id: &str) -> Result> { - use pmosource::AudioFormat; - - Ok(vec![ - AudioFormat { - format_id: "mp3-128".to_string(), - mime_type: "audio/mpeg".to_string(), - sample_rate: Some(44100), - bit_depth: None, - bitrate: Some(128), - channels: Some(2), - }, - AudioFormat { - format_id: "aac-64".to_string(), - mime_type: "audio/aac".to_string(), - sample_rate: Some(44100), - bit_depth: None, - bitrate: Some(64), - channels: Some(2), - }, - AudioFormat { - format_id: "aac-128".to_string(), - mime_type: "audio/aac".to_string(), - sample_rate: Some(44100), - bit_depth: None, - bitrate: Some(128), - channels: Some(2), - }, - AudioFormat { - format_id: "aac-320".to_string(), - mime_type: "audio/aac".to_string(), - sample_rate: Some(44100), - bit_depth: None, - bitrate: Some(320), - channels: Some(2), - }, - AudioFormat { - format_id: "flac".to_string(), - mime_type: "audio/flac".to_string(), - sample_rate: Some(44100), - bit_depth: Some(16), - bitrate: None, - channels: Some(2), - }, - ]) + Ok(vec![pmosource::AudioFormat { + format_id: "flac".to_string(), + mime_type: "audio/flac".to_string(), + sample_rate: Some(44100), + bit_depth: Some(16), + bitrate: None, + channels: Some(2), + }]) } - async fn get_cache_status(&self, object_id: &str) -> Result { - let (channel_id, _, _) = parse_track_identifier(object_id) + async fn get_cache_status(&self, object_id: &str) -> Result { + let channel_id = parse_track_channel(object_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; let channel = self - .channel_state(channel_id) + .channel(channel_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; - channel.cache_manager.get_cache_status(object_id).await + channel + .cache_manager() + .get_cache_status(object_id) + .await + .map_err(|e| MusicSourceError::CacheError(e.to_string())) } - async fn cache_item(&self, object_id: &str) -> Result { - let (channel_id, _, _) = parse_track_identifier(object_id) + async fn cache_item(&self, object_id: &str) -> Result { + let channel_id = parse_track_channel(object_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; let channel = self - .channel_state(channel_id) + .channel(channel_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; - self.ensure_channel_ready(channel.clone()).await?; - channel.cache_manager.get_cache_status(object_id).await + channel + .cache_manager() + .get_cache_status(object_id) + .await + .map_err(|e| MusicSourceError::CacheError(e.to_string())) } async fn browse_paginated( @@ -1100,20 +499,12 @@ impl MusicSource for RadioParadiseSource { } _ => { if let Some(channel_id) = parse_channel_container_id(object_id) { - let channel = self - .channel_state(channel_id) + let descriptor = ALL_CHANNELS + .iter() + .find(|desc| desc.id == channel_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; - self.ensure_channel_ready(channel.clone()).await?; - let len = channel.playlist.len().await; - if offset >= len { - return Ok(BrowseResult::Items(Vec::new())); - } - let count = if limit == 0 { - len - offset - } else { - limit.min(len - offset) - }; - let items = channel.playlist.as_objects(offset, count, None).await; + + let items = self.channel_items(*descriptor, offset, Some(limit)).await?; Ok(BrowseResult::Items(items)) } else { Err(MusicSourceError::ObjectNotFound(object_id.to_string())) @@ -1125,14 +516,13 @@ impl MusicSource for RadioParadiseSource { async fn get_item_count(&self, object_id: &str) -> Result { match object_id { "0" => Ok(1), - "radio-paradise" => Ok(CHANNELS.len()), + "radio-paradise" => Ok(ALL_CHANNELS.len()), _ => { if let Some(channel_id) = parse_channel_container_id(object_id) { let channel = self - .channel_state(channel_id) + .channel(channel_id) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; - self.ensure_channel_ready(channel.clone()).await?; - Ok(channel.playlist.len().await) + Ok(channel.playlist().active_len().await) } else { Err(MusicSourceError::ObjectNotFound(object_id.to_string())) } @@ -1140,22 +530,20 @@ impl MusicSource for RadioParadiseSource { } } - async fn statistics(&self) -> Result { - let mut total_items = 0usize; - let mut cached_items = 0usize; + async fn statistics(&self) -> Result { + let mut total_tracks = 0usize; + let mut cached_tracks = 0usize; - for descriptor in CHANNELS.iter() { - if let Some(channel) = self.channel_state(descriptor.id) { - total_items += channel.playlist.len().await; - let stats = channel.cache_manager.statistics().await; - cached_items += stats.cached_tracks; - } + for (_, channel) in self.channels_iter() { + total_tracks += channel.playlist().active_len().await; + let stats = channel.cache_manager().statistics().await; + cached_tracks += stats.cached_tracks; } - Ok(pmosource::SourceStatistics { - total_items: Some(total_items), - total_containers: Some(CHANNELS.len() + 1), - cached_items: Some(cached_items), + Ok(SourceStatistics { + total_items: Some(total_tracks), + total_containers: Some(ALL_CHANNELS.len() + 1), + cached_items: Some(cached_tracks), cache_size_bytes: None, }) } diff --git a/pmoserver/Cargo.toml b/pmoserver/Cargo.toml index c2388bb8..01645833 100644 --- a/pmoserver/Cargo.toml +++ b/pmoserver/Cargo.toml @@ -20,4 +20,4 @@ axum-server = "0.7.2" axum-embed = "0.1.0" rust-embed = "8.7.2" utoipa = { version = "5.4.0", features = ["axum_extras"] } -utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } diff --git a/pmosource/src/api.rs b/pmosource/src/api.rs index c4ea5898..2ca70758 100644 --- a/pmosource/src/api.rs +++ b/pmosource/src/api.rs @@ -24,7 +24,7 @@ use axum::{ extract::{Path, Query}, http::{header, StatusCode}, response::{IntoResponse, Response}, - routing::{delete, get}, + routing::{get, post}, Json, Router, }; @@ -32,7 +32,9 @@ use axum::{ use serde::{Deserialize, Serialize}; #[cfg(feature = "server")] -use crate::{MusicSource, SourceCapabilities, SourceStatistics}; +use crate::{ + AudioFormat, CacheStatus, MusicSource, MusicSourceError, SourceCapabilities, SourceStatistics, +}; #[cfg(feature = "server")] use std::sync::Arc; @@ -222,6 +224,151 @@ pub struct BrowseItemInfo { pub resources: Vec, } +/// Paramètres gĂ©nĂ©riques pour cibler un objet d'une source +#[cfg(feature = "server")] +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub struct ObjectQuery { + /// ID de l'objet (container ou item) + pub object_id: String, +} + +/// RĂ©ponse pour la rĂ©solution d'URI d'un objet +#[cfg(feature = "server")] +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ResolveUriResponse { + /// ID de l'objet demandĂ© + pub object_id: String, + /// URI rĂ©solue (cache ou origine) + pub uri: String, +} + +/// États possibles pour le cache +#[cfg(feature = "server")] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CacheStatusState { + NotCached, + Caching, + Cached, + Failed, +} + +/// Informations dĂ©taillĂ©es sur le cache d'un objet +#[cfg(feature = "server")] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct CacheStatusInfo { + /// État du cache + pub status: CacheStatusState, + /// Progression (0.0 - 1.0) + pub progress: Option, + /// Taille en octets si connue + pub size_bytes: Option, + /// Message d'erreur Ă©ventuel + pub error: Option, +} + +#[cfg(feature = "server")] +impl From for CacheStatusInfo { + fn from(status: CacheStatus) -> Self { + match status { + CacheStatus::NotCached => Self { + status: CacheStatusState::NotCached, + progress: Some(0.0), + size_bytes: None, + error: None, + }, + CacheStatus::Caching { progress } => Self { + status: CacheStatusState::Caching, + progress: Some(progress), + size_bytes: None, + error: None, + }, + CacheStatus::Cached { size_bytes } => Self { + status: CacheStatusState::Cached, + progress: Some(1.0), + size_bytes: Some(size_bytes), + error: None, + }, + CacheStatus::Failed { error } => Self { + status: CacheStatusState::Failed, + progress: None, + size_bytes: None, + error: Some(error), + }, + } + } +} + +/// Corps de requĂŞte pour dĂ©clencher la mise en cache +#[cfg(feature = "server")] +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CacheRequest { + /// ID de l'objet Ă  mettre en cache + pub object_id: String, +} + +/// RĂ©ponse standard pour les endpoints de cache +#[cfg(feature = "server")] +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct CacheStatusResponse { + /// ID de l'objet + pub object_id: String, + /// Informations de cache + pub status: CacheStatusInfo, +} + +/// Paramètres pour rĂ©cupĂ©rer les formats disponibles +#[cfg(feature = "server")] +#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub struct FormatsQuery { + /// ID de l'objet ciblĂ© + pub object_id: String, +} + +/// Description d'un format audio disponible +#[cfg(feature = "server")] +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AudioFormatInfo { + /// Identifiant technique du format + pub format_id: String, + /// MIME type (`audio/flac`, `audio/mpeg`, ...) + pub mime_type: String, + /// FrĂ©quence d'Ă©chantillonnage (Hz) + pub sample_rate: Option, + /// Profondeur de bits + pub bit_depth: Option, + /// DĂ©bit en kbps (lossy) + pub bitrate: Option, + /// Nombre de canaux + pub channels: Option, +} + +#[cfg(feature = "server")] +impl From for AudioFormatInfo { + fn from(format: AudioFormat) -> Self { + Self { + format_id: format.format_id, + mime_type: format.mime_type, + sample_rate: format.sample_rate, + bit_depth: format.bit_depth, + bitrate: format.bitrate, + channels: format.channels, + } + } +} + +/// RĂ©ponse contenant les formats disponibles pour un objet +#[cfg(feature = "server")] +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AudioFormatsResponse { + /// ID de l'objet + pub object_id: String, + /// Liste des formats supportĂ©s + pub formats: Vec, +} + #[cfg(feature = "server")] impl From<&DidlContainer> for BrowseContainerInfo { fn from(container: &DidlContainer) -> Self { @@ -651,6 +798,234 @@ async fn browse_source( } } +/// RĂ©sout l'URI rĂ©elle d'un objet (cache ou origine) +#[cfg(feature = "server")] +#[utoipa::path( + get, + path = "/{id}/resolve", + params( + ("id" = String, Path, description = "ID de la source"), + ObjectQuery + ), + responses( + (status = 200, description = "URI rĂ©solue", body = ResolveUriResponse), + (status = 404, description = "Source ou objet introuvable", body = ErrorResponse), + (status = 500, description = "Erreur lors de la rĂ©solution de l'URI", body = ErrorResponse), + ), + tag = "sources" +)] +async fn resolve_source_uri( + Path(id): Path, + Query(params): Query, +) -> impl IntoResponse { + match get_source(&id).await { + Some(source) => match source.resolve_uri(¶ms.object_id).await { + Ok(uri) => { + let response = ResolveUriResponse { + object_id: params.object_id, + uri, + }; + (StatusCode::OK, Json(response)).into_response() + } + Err(MusicSourceError::ObjectNotFound(_)) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Object not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to resolve URI: {}", e), + }), + ) + .into_response(), + }, + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response(), + } +} + +/// RĂ©cupère le statut du cache pour un objet +#[cfg(feature = "server")] +#[utoipa::path( + get, + path = "/{id}/cache/status", + params( + ("id" = String, Path, description = "ID de la source"), + ObjectQuery + ), + responses( + (status = 200, description = "Statut du cache", body = CacheStatusResponse), + (status = 404, description = "Source ou objet introuvable", body = ErrorResponse), + (status = 501, description = "Cache non supportĂ©", body = ErrorResponse), + (status = 500, description = "Erreur lors de la rĂ©cupĂ©ration du statut du cache", body = ErrorResponse), + ), + tag = "sources" +)] +async fn get_source_cache_status( + Path(id): Path, + Query(params): Query, +) -> impl IntoResponse { + match get_source(&id).await { + Some(source) => match source.get_cache_status(¶ms.object_id).await { + Ok(status) => { + let response = CacheStatusResponse { + object_id: params.object_id, + status: CacheStatusInfo::from(status), + }; + (StatusCode::OK, Json(response)).into_response() + } + Err(MusicSourceError::ObjectNotFound(_)) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Object not found".to_string(), + }), + ) + .into_response(), + Err(MusicSourceError::NotSupported(msg)) => ( + StatusCode::NOT_IMPLEMENTED, + Json(ErrorResponse { error: msg }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to get cache status: {}", e), + }), + ) + .into_response(), + }, + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response(), + } +} + +/// Demande la mise en cache d'un objet +#[cfg(feature = "server")] +#[utoipa::path( + post, + path = "/{id}/cache", + params( + ("id" = String, Path, description = "ID de la source") + ), + request_body = CacheRequest, + responses( + (status = 200, description = "RequĂŞte de cache enregistrĂ©e", body = CacheStatusResponse), + (status = 404, description = "Source ou objet introuvable", body = ErrorResponse), + (status = 501, description = "Cache non supportĂ©", body = ErrorResponse), + (status = 500, description = "Erreur lors de la mise en cache", body = ErrorResponse), + ), + tag = "sources" +)] +async fn request_source_cache( + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + match get_source(&id).await { + Some(source) => match source.cache_item(&payload.object_id).await { + Ok(status) => { + let response = CacheStatusResponse { + object_id: payload.object_id, + status: CacheStatusInfo::from(status), + }; + (StatusCode::OK, Json(response)).into_response() + } + Err(MusicSourceError::ObjectNotFound(_)) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Object not found".to_string(), + }), + ) + .into_response(), + Err(MusicSourceError::NotSupported(msg)) => ( + StatusCode::NOT_IMPLEMENTED, + Json(ErrorResponse { error: msg }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to cache item: {}", e), + }), + ) + .into_response(), + }, + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response(), + } +} + +/// RĂ©cupère les formats audio disponibles pour un objet +#[cfg(feature = "server")] +#[utoipa::path( + get, + path = "/{id}/formats", + params( + ("id" = String, Path, description = "ID de la source"), + FormatsQuery + ), + responses( + (status = 200, description = "Formats disponibles", body = AudioFormatsResponse), + (status = 404, description = "Source ou objet introuvable", body = ErrorResponse), + (status = 500, description = "Erreur lors de la rĂ©cupĂ©ration des formats", body = ErrorResponse), + ), + tag = "sources" +)] +async fn get_source_formats( + Path(id): Path, + Query(params): Query, +) -> impl IntoResponse { + match get_source(&id).await { + Some(source) => match source.get_available_formats(¶ms.object_id).await { + Ok(formats) => { + let response = AudioFormatsResponse { + object_id: params.object_id, + formats: formats.into_iter().map(AudioFormatInfo::from).collect(), + }; + (StatusCode::OK, Json(response)).into_response() + } + Err(MusicSourceError::ObjectNotFound(_)) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Object not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to get available formats: {}", e), + }), + ) + .into_response(), + }, + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response(), + } +} + /// DĂ©senregistre une source musicale /// /// Supprime une source du registre par son ID. @@ -718,6 +1093,10 @@ pub fn create_sources_router() -> Router { .route("/{id}/root", get(get_source_root)) .route("/{id}/browse", get(browse_source)) .route("/{id}/image", get(get_source_image)) + .route("/{id}/resolve", get(resolve_source_uri)) + .route("/{id}/cache/status", get(get_source_cache_status)) + .route("/{id}/cache", post(request_source_cache)) + .route("/{id}/formats", get(get_source_formats)) } /// Structure pour la documentation OpenAPI de base @@ -736,6 +1115,10 @@ pub fn create_sources_router() -> Router { get_source_root, browse_source, get_source_image, + resolve_source_uri, + get_source_cache_status, + request_source_cache, + get_source_formats, unregister_source_handler, ), components( @@ -749,6 +1132,13 @@ pub fn create_sources_router() -> Router { BrowseItemResourceInfo, BrowseItemInfo, SourceBrowseResponse, + ResolveUriResponse, + CacheStatusState, + CacheStatusInfo, + CacheStatusResponse, + CacheRequest, + AudioFormatInfo, + AudioFormatsResponse, ErrorResponse, ) ), diff --git a/pmosource/src/cache.rs b/pmosource/src/cache.rs index ae70ec63..4c08d4b9 100644 --- a/pmosource/src/cache.rs +++ b/pmosource/src/cache.rs @@ -282,6 +282,16 @@ impl SourceCacheManager { collection_id: self.collection_id.clone(), } } + + /// RĂ©cupère le chemin de fichier pour une piste audio en cache + /// + /// Retourne `None` si le fichier n'est pas encore disponible. + pub async fn audio_file_path(&self, pk: &str) -> Option { + match self.audio_cache.get(pk).await { + Ok(path) => Some(path), + Err(_) => None, + } + } } /// Statistiques du cache pour une source