- ⏳ Loading channel tracks…
+
+ ⏳ Loading playlist…
-
- {{ channelBrowse.containers.length }} sub container(s) available.
-
-
+
{{ 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 }}
+
+
- No cached tracks yet for this channel. Refresh after playback starts.
+ No tracks currently queued. Try refreshing after playback starts.
+
+
+
+
+
+
+
+ Last refresh: {{ formatTimestamp(channelHistoryLastUpdated) }}
+
+
+ ❌ {{ 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 6bb75df6..00000000
Binary files a/pmoparadise/.pmomusic_audio/cache.db and /dev/null differ
diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml
index 8f3b7a26..5de8ff9b 100644
--- a/pmoparadise/Cargo.toml
+++ b/pmoparadise/Cargo.toml
@@ -19,6 +19,16 @@ tokio = { version = "1", features = ["full"] }
# Sérialisation/Désérialisation JSON
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+serde_yaml = "0.9"
+
+# Helpers
+chrono = { version = "0.4", features = ["serde"] }
+async-trait = "0.1"
+sha2 = "0.10"
+hex = "0.4"
+tokio-util = { version = "0.7", features = ["io"] }
+async-stream = "0.3"
+rusqlite = { version = "0.37", features = ["bundled"] }
# Gestion des erreurs
thiserror = "1.0"
@@ -54,6 +64,7 @@ pmosource = { path = "../pmosource" }
# Playlist management for FIFO support
pmoplaylist = { path = "../pmoplaylist" }
+pmoconfig = { path = "../pmoconfig" }
# Cache support (OBLIGATOIRE - architecture refactorisée)
pmocovers = { path = "../pmocovers" }
@@ -70,7 +81,7 @@ metadata-only = []
# Active le décodage FLAC par-track
per-track = ["dep:claxon", "dep:hound", "dep:tempfile"]
# Active l'API REST pmoserver
-pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum"]
+pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
# Active le media server UPnP (includes pmoserver)
mediaserver = ["dep:pmoupnp", "dep:pmodidl", "dep:uuid", "pmoserver"]
# Feature pour activer le support serveur (cache registry)
diff --git a/pmoparadise/src/client.rs b/pmoparadise/src/client.rs
index 423bc99f..95ab5777 100644
--- a/pmoparadise/src/client.rs
+++ b/pmoparadise/src/client.rs
@@ -6,11 +6,30 @@ use reqwest::Client;
use std::time::Duration;
use url::Url;
-fn normalize_base_url(base: &str) -> 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