update la webapp

This commit is contained in:
2025-11-25 08:24:08 +01:00
parent 3957e17f64
commit aeddcc9c64
17 changed files with 1550 additions and 78 deletions

View File

@@ -44,9 +44,7 @@
</div>
<div class="api-body">
<p v-if="api.description" class="api-description">
{{ api.description }}
</p>
<div v-if="api.description" class="api-description" v-html="renderMarkdown(api.description)"></div>
<p v-else class="api-description empty">Aucune description disponible</p>
<div class="api-stats">
@@ -86,6 +84,14 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
// Configurer marked pour un rendu simple
marked.setOptions({
breaks: true,
gfm: true,
});
interface ApiRegistryEntry {
name: string;
@@ -142,6 +148,14 @@ function getApiIcon(name: string): string {
return icons[name.toLowerCase()] || '🔧';
}
function renderMarkdown(markdown: string): string {
const rawHtml = marked.parse(markdown, { async: false }) as string;
return DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
ALLOWED_ATTR: ['href', 'target', 'class']
});
}
onMounted(() => {
fetchRegistry();
});
@@ -332,6 +346,66 @@ onMounted(() => {
font-style: italic;
}
/* Styles pour le contenu markdown rendu */
.api-description :deep(p) {
margin: 0 0 0.5rem 0;
}
.api-description :deep(p:last-child) {
margin-bottom: 0;
}
.api-description :deep(strong) {
font-weight: 600;
color: #2d3748;
}
.api-description :deep(em) {
font-style: italic;
}
.api-description :deep(code) {
background: #f7fafc;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.875em;
color: #d63384;
}
.api-description :deep(pre) {
background: #f7fafc;
padding: 0.75rem;
border-radius: 6px;
overflow-x: auto;
margin: 0.5rem 0;
}
.api-description :deep(pre code) {
background: none;
padding: 0;
color: inherit;
}
.api-description :deep(ul),
.api-description :deep(ol) {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.api-description :deep(li) {
margin: 0.25rem 0;
}
.api-description :deep(a) {
color: #667eea;
text-decoration: none;
}
.api-description :deep(a:hover) {
text-decoration: underline;
}
.api-stats {
display: flex;
flex-direction: column;

View File

@@ -76,7 +76,14 @@
@click="selectedTrack = track"
>
<div class="track-icon">
<div class="music-icon">🎵</div>
<img
v-if="getTrackCoverUrl(track, 400)"
:src="getTrackCoverUrl(track, 400)"
:alt="`Cover for ${track.metadata?.title || 'Unknown'}`"
class="cover-image"
@error="handleCoverError(track.pk)"
/>
<div v-else class="music-icon">🎵</div>
<div class="track-overlay">
<span class="hits">{{ track.hits }} plays</span>
</div>
@@ -138,7 +145,14 @@
<div class="modal-content" @click.stop>
<button class="modal-close" @click="selectedTrack = null"></button>
<div class="modal-header">
<div class="modal-icon">🎵</div>
<img
v-if="getTrackCoverUrl(selectedTrack, 200)"
:src="getTrackCoverUrl(selectedTrack, 200)"
:alt="`Cover for ${selectedTrack.metadata?.title || 'Unknown'}`"
class="modal-cover-image"
@error="handleCoverError(selectedTrack.pk)"
/>
<div v-else class="modal-icon">🎵</div>
<h3>Track Details</h3>
</div>
<div class="modal-info">
@@ -221,6 +235,7 @@ import {
formatDuration,
formatBitrate,
formatSampleRate,
getCoverUrl,
} from "../services/audioCache";
// --- États ---
@@ -246,6 +261,9 @@ const deletingTracks = ref(new Set<string>());
const isPlaying = ref(false);
const audioError = ref("");
// Gestion des erreurs de chargement des covers
const failedCovers = ref(new Set<string>());
// --- Computed ---
const totalHits = computed(() => tracks.value.reduce((sum, t) => sum + t.hits, 0));
@@ -449,6 +467,15 @@ function conversionLabel(track: AudioCacheEntry | null): string | undefined {
return formatConversion(track?.metadata?.conversion ?? undefined);
}
function getTrackCoverUrl(track: AudioCacheEntry | null, size?: number): string | undefined {
if (!track || failedCovers.value.has(track.pk)) return undefined;
return getCoverUrl(track.metadata, size);
}
function handleCoverError(pk: string) {
failedCovers.value.add(pk);
}
onMounted(() => {
refreshTracks();
});
@@ -692,6 +719,15 @@ button:disabled {
overflow: hidden;
}
.cover-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.music-icon {
position: absolute;
top: 50%;
@@ -838,6 +874,14 @@ button:disabled {
gap: 1rem;
}
.modal-cover-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.modal-icon {
font-size: 3rem;
}

View File

@@ -70,10 +70,10 @@
>
<div class="image-wrapper">
<img
:src="getImageUrl(image.pk, 256)"
:src="getImageUrlWithFallback(image.pk, 256)"
:alt="resolveOrigin(image) || image.pk"
loading="lazy"
@error="handleImageError"
@error="(e) => handleImageError(image.pk, e)"
/>
<div class="image-overlay">
<span class="hits">👁️ {{ image.hits }}</span>
@@ -107,9 +107,10 @@
<div class="modal-content" @click.stop>
<button class="modal-close" @click="selectedImage = null">✕</button>
<img
:src="getImageUrl(selectedImage.pk)"
:src="getImageUrlWithFallback(selectedImage.pk)"
:alt="resolveOrigin(selectedImage) || selectedImage.pk"
class="modal-image"
@error="(e) => selectedImage && handleImageError(selectedImage.pk, e)"
/>
<div class="modal-info">
<h3>Image Details</h3>
@@ -147,6 +148,7 @@ import {
getImageUrl,
getOriginUrl,
waitForDownload,
getDefaultImageUrl,
} from "../services/coverCache";
// --- États ---
@@ -166,6 +168,9 @@ const isConsolidating = ref(false);
const isPurging = ref(false);
const deletingImages = ref(new Set<string>());
// Gestion des erreurs de chargement d'images
const failedImages = ref(new Set<string>());
// --- Computed ---
const totalHits = computed(() => images.value.reduce((sum, i) => sum + i.hits, 0));
@@ -246,7 +251,18 @@ function formatDate(dateString:string){
const d=new Date(dateString), diff=Date.now()-d.getTime(), days=Math.floor(diff/(1000*60*60*24));
if(days===0)return"Today"; if(days===1)return"Yesterday"; if(days<7)return`${days} days ago`; return d.toLocaleDateString();
}
function handleImageError(e:Event){(e.target as HTMLImageElement).src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Crect fill='%23333' width='256' height='256'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-size='20'%3EError%3C/text%3E%3C/svg%3E";}
function getImageUrlWithFallback(pk: string, size?: number): string {
if (failedImages.value.has(pk)) {
return getDefaultImageUrl();
}
return getImageUrl(pk, size);
}
function handleImageError(pk: string, event: Event) {
failedImages.value.add(pk);
(event.target as HTMLImageElement).src = getDefaultImageUrl();
}
onMounted(()=>refreshImages());
</script>

View File

@@ -194,11 +194,11 @@
<div v-else class="player-content">
<!-- Cover Art & Metadata -->
<div class="player-info">
<div v-if="playerMetadata?.image_url" class="player-cover">
<img :src="playerMetadata.image_url" :alt="playerMetadata.title || 'Album cover'">
</div>
<div v-else class="player-cover-placeholder">
🎵
<div class="player-cover">
<img
:src="playerMetadata?.cover_url || nowPlaying?.current_song?.cover_url || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iIzFhMWEyZSIvPjxwYXRoIGQ9Ik0xMDAgNDAgTDEwMCAxMjAgTTcwIDkwIEwxMzAgOTAiIHN0cm9rZT0iIzAwZDRmZiIgc3Ryb2tlLXdpZHRoPSI4IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48Y2lyY2xlIGN4PSI4NSIgY3k9IjE0MCIgcj0iMTUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwZDRmZiIgc3Ryb2tlLXdpZHRoPSI0Ii8+PGNpcmNsZSBjeD0iMTE1IiBjeT0iMTQwIiByPSIxNSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDBkNGZmIiBzdHJva2Utd2lkdGg9IjQiLz48L3N2Zz4='"
:alt="playerMetadata?.title || nowPlaying?.current_song?.title || 'Album cover'"
>
</div>
<div class="player-metadata">
@@ -439,6 +439,144 @@
</div>
</div>
<!-- Test New Endpoints -->
<div class="test-endpoints-section">
<h3>🧪 Test Nouveaux Endpoints</h3>
<div class="test-grid">
<!-- Test getCoverUrl -->
<div class="test-card">
<h4>Test: Cover URL avec Fallback</h4>
<div class="test-controls">
<input
v-model.number="testCoverEventId"
type="number"
placeholder="Event ID"
class="test-input"
/>
<input
v-model.number="testCoverSongIndex"
type="number"
placeholder="Song Index"
class="test-input"
/>
<button
@click="testGetCoverUrl"
:disabled="testCoverLoading || !testCoverEventId"
class="btn-secondary"
>
<span v-if="testCoverLoading"> Test...</span>
<span v-else>Test Cover URL</span>
</button>
</div>
<div v-if="testCoverResult" class="test-result">
<div class="result-item">
<strong>Source:</strong> {{ testCoverResult.cover_type }}
</div>
<div v-if="testCoverResult.cover_url" class="result-item">
<strong>URL:</strong>
<a :href="testCoverResult.cover_url" target="_blank" class="stream-link">
{{ testCoverResult.cover_url }}
</a>
</div>
<div v-else class="result-item">
<strong>URL:</strong> <span class="text-muted">None (fallback manquant)</span>
</div>
<div v-if="testCoverResult.cover_url" class="cover-preview">
<img :src="testCoverResult.cover_url" alt="Cover preview" />
</div>
</div>
<div v-if="testCoverError" class="error-message">
{{ testCoverError }}
</div>
</div>
<!-- Test getStreamUrl -->
<div class="test-card">
<h4>Test: Stream URL Direct</h4>
<div class="test-controls">
<input
v-model.number="testStreamEventId"
type="number"
placeholder="Event ID"
class="test-input"
/>
<button
@click="testGetStreamUrl"
:disabled="testStreamLoading || !testStreamEventId"
class="btn-secondary"
>
<span v-if="testStreamLoading"> Test...</span>
<span v-else>Test Stream URL</span>
</button>
</div>
<div v-if="testStreamResult" class="test-result">
<div class="result-item">
<strong>Event:</strong> {{ testStreamResult.event }}
</div>
<div class="result-item">
<strong>Duration:</strong> {{ formatDuration(testStreamResult.length_ms) }}
</div>
<div class="result-item">
<strong>URL:</strong>
<a :href="testStreamResult.stream_url" target="_blank" class="stream-link">
{{ testStreamResult.stream_url }}
</a>
</div>
</div>
<div v-if="testStreamError" class="error-message">
{{ testStreamError }}
</div>
</div>
<!-- Test getSongByIndex -->
<div class="test-card">
<h4>Test: Morceau par Index</h4>
<div class="test-controls">
<input
v-model.number="testSongEventId"
type="number"
placeholder="Event ID"
class="test-input"
/>
<input
v-model.number="testSongIndex"
type="number"
placeholder="Song Index"
class="test-input"
/>
<button
@click="testGetSongByIndex"
:disabled="testSongLoading || !testSongEventId"
class="btn-secondary"
>
<span v-if="testSongLoading"> Test...</span>
<span v-else>Test Get Song</span>
</button>
</div>
<div v-if="testSongResult" class="test-result">
<div class="result-item">
<strong>Title:</strong> {{ testSongResult.title }}
</div>
<div class="result-item">
<strong>Artist:</strong> {{ testSongResult.artist }}
</div>
<div class="result-item">
<strong>Album:</strong> {{ testSongResult.album }}
</div>
<div class="result-item">
<strong>Duration:</strong> {{ formatDuration(testSongResult.duration_ms) }}
</div>
<div v-if="testSongResult.cover_url" class="cover-preview-small">
<img :src="testSongResult.cover_url" alt="Song cover" />
</div>
</div>
<div v-if="testSongError" class="error-message">
{{ testSongError }}
</div>
</div>
</div>
</div>
<!-- Available Channels -->
<div v-if="channelsError" class="error-message inline-error">
{{ channelsError }}
@@ -463,6 +601,16 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import {
listChannels,
getNowPlaying,
getCurrentBlock,
getBlockById,
getSongByIndex,
getCoverUrl,
getStreamUrl,
formatDuration as formatDurationUtil,
} from '../services/radioParadise'
const API_BASE = '/api/radioparadise'
const SOURCE_API_BASE = '/api/sources'
@@ -564,6 +712,24 @@ const blockSearchLoading = ref(false)
const blockSearchError = ref('')
const channelsError = ref('')
// Test new endpoints state
const testCoverEventId = ref(null)
const testCoverSongIndex = ref(0)
const testCoverResult = ref(null)
const testCoverLoading = ref(false)
const testCoverError = ref('')
const testStreamEventId = ref(null)
const testStreamResult = ref(null)
const testStreamLoading = ref(false)
const testStreamError = ref('')
const testSongEventId = ref(null)
const testSongIndex = ref(0)
const testSongResult = ref(null)
const testSongLoading = ref(false)
const testSongError = ref('')
// Stream metadata state
const streamMetadata = ref(null)
const streamMetadataLoading = ref(false)
@@ -594,29 +760,8 @@ function formatTimestamp(date) {
return date.toLocaleTimeString()
}
function buildQuery(extra = {}) {
const params = new URLSearchParams()
if (selectedChannel.value != null) {
params.set('channel', selectedChannel.value.toString())
}
Object.entries(extra).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value))
}
})
const query = params.toString()
return query ? `?${query}` : ''
}
async function fetchBlockByEvent(eventId) {
const query = buildQuery()
const response = await fetch(`${API_BASE}/block/${eventId}${query}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return await response.json()
return await getBlockById(eventId, selectedChannel.value)
}
function playAudio(url) {
@@ -649,12 +794,7 @@ async function refreshNowPlaying() {
error.value = null
try {
const query = buildQuery()
const response = await fetch(`${API_BASE}/now-playing${query}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
nowPlaying.value = await response.json()
nowPlaying.value = await getNowPlaying(selectedChannel.value)
lastUpdated.value = new Date()
upcomingBlock.value = null
upcomingError.value = ''
@@ -715,8 +855,8 @@ function updateMediaSession(metadata) {
title: metadata.title || 'Unknown Title',
artist: metadata.artist || 'Unknown Artist',
album: metadata.album || 'Radio Paradise',
artwork: metadata.image_url ? [
{ src: metadata.image_url, sizes: '512x512', type: 'image/jpeg' }
artwork: metadata.cover_url ? [
{ src: metadata.cover_url, sizes: '512x512', type: 'image/jpeg' }
] : []
})
@@ -762,11 +902,7 @@ function stopPlayerMetadataRefresh() {
// Fetch available channels
async function fetchChannels() {
try {
const response = await fetch(`${API_BASE}/channels`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const data = await listChannels()
channels.value = data
channelsError.value = ''
@@ -914,6 +1050,69 @@ function clearBlockSearch() {
blockSearchError.value = ''
}
// Test new endpoints functions
async function testGetCoverUrl() {
if (!testCoverEventId.value) return
testCoverLoading.value = true
testCoverError.value = ''
testCoverResult.value = null
try {
testCoverResult.value = await getCoverUrl(
testCoverEventId.value,
testCoverSongIndex.value,
selectedChannel.value
)
} catch (e) {
console.error('Failed to test cover URL:', e)
testCoverError.value = `Failed: ${e.message}`
} finally {
testCoverLoading.value = false
}
}
async function testGetStreamUrl() {
if (!testStreamEventId.value) return
testStreamLoading.value = true
testStreamError.value = ''
testStreamResult.value = null
try {
testStreamResult.value = await getStreamUrl(
testStreamEventId.value,
selectedChannel.value
)
} catch (e) {
console.error('Failed to test stream URL:', e)
testStreamError.value = `Failed: ${e.message}`
} finally {
testStreamLoading.value = false
}
}
async function testGetSongByIndex() {
if (!testSongEventId.value) return
testSongLoading.value = true
testSongError.value = ''
testSongResult.value = null
try {
testSongResult.value = await getSongByIndex(
testSongEventId.value,
testSongIndex.value,
selectedChannel.value
)
} catch (e) {
console.error('Failed to test get song:', e)
testSongError.value = `Failed: ${e.message}`
} finally {
testSongLoading.value = false
}
}
// Initialize on mount
onMounted(async () => {
await fetchChannels()
@@ -1802,19 +2001,6 @@ onUnmounted(() => {
border: 2px solid rgba(0, 212, 255, 0.2);
}
.player-cover-placeholder {
width: 180px;
height: 180px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2a2a3e 0%, #1a1a2e 100%);
border-radius: 12px;
border: 2px dashed rgba(0, 212, 255, 0.3);
font-size: 4rem;
color: rgba(0, 212, 255, 0.3);
}
.player-metadata {
flex: 1;
display: flex;
@@ -2231,6 +2417,123 @@ onUnmounted(() => {
min-width: 120px;
}
/* Test Endpoints Section */
.test-endpoints-section {
background: linear-gradient(135deg, #1a2a1a 0%, #1a1a1a 100%);
border-radius: 8px;
padding: 20px;
margin: 30px 0;
border: 1px solid rgba(46, 204, 113, 0.3);
}
.test-endpoints-section h3 {
margin-top: 0;
color: #2ecc71;
margin-bottom: 16px;
}
.test-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.test-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(46, 204, 113, 0.2);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.test-card h4 {
margin: 0 0 8px 0;
color: #2ecc71;
font-size: 1rem;
font-weight: 600;
}
.test-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.test-input {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #333;
background: #111;
color: #fff;
font-size: 0.9rem;
}
.test-input:focus {
outline: none;
border-color: #2ecc71;
box-shadow: 0 0 6px rgba(46, 204, 113, 0.2);
}
.test-result {
background: rgba(46, 204, 113, 0.05);
border: 1px solid rgba(46, 204, 113, 0.15);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.result-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
}
.result-item strong {
color: #2ecc71;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.text-muted {
color: #666;
font-style: italic;
}
.cover-preview {
margin-top: 8px;
display: flex;
justify-content: center;
}
.cover-preview img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
border: 2px solid rgba(46, 204, 113, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.cover-preview-small {
margin-top: 8px;
display: flex;
justify-content: center;
}
.cover-preview-small img {
max-width: 150px;
max-height: 150px;
border-radius: 6px;
border: 2px solid rgba(46, 204, 113, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
@media (max-width: 768px) {
.header {
flex-direction: column;
@@ -2285,8 +2588,7 @@ onUnmounted(() => {
text-align: center;
}
.player-cover img,
.player-cover-placeholder {
.player-cover img {
width: 150px;
height: 150px;
}
@@ -2306,5 +2608,14 @@ onUnmounted(() => {
.player-stream-info {
grid-template-columns: 1fr;
}
/* Test Section Responsive */
.test-grid {
grid-template-columns: 1fr;
}
.cover-preview img {
max-height: 200px;
}
}
</style>

View File

@@ -19,6 +19,8 @@ export interface AudioCacheMetadata {
bitrate?: number;
channels?: number;
conversion?: ConversionInfo;
cover_pk?: string;
cover_url?: string;
[key: string]: unknown;
}
@@ -228,3 +230,26 @@ export function formatSampleRate(sampleRate?: number): string {
if (!sampleRate) return "Unknown";
return `${(sampleRate / 1000).toFixed(1)} kHz`;
}
/**
* Génère l'URL de la cover d'une piste
* Priorité : cover_pk (cache) > cover_url (externe) > undefined
*/
export function getCoverUrl(metadata?: AudioCacheMetadata | null, size?: number): string | undefined {
if (!metadata) return undefined;
// Priorité 1 : cover en cache via cover_pk
if (metadata.cover_pk) {
if (size) {
return `/covers/image/${metadata.cover_pk}/${size}`;
}
return `/covers/image/${metadata.cover_pk}`;
}
// Priorité 2 : cover externe via cover_url
if (metadata.cover_url) {
return metadata.cover_url;
}
return undefined;
}

View File

@@ -192,3 +192,35 @@ export function getImageUrl(pk: string, size?: number): string {
}
return `/covers/image/${pk}`;
}
/**
* SVG par défaut pour les images qui ne se chargent pas
*/
const DEFAULT_COVER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgGrad)"/>
<g transform="translate(200, 200)">
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
</g>
<text x="200" y="360" text-anchor="middle"
font-family="system-ui, -apple-system, sans-serif"
font-size="20" fill="white" opacity="0.6">
No Image Available
</text>
</svg>`;
/**
* Retourne l'URL de l'image par défaut comme data URL
*/
export function getDefaultImageUrl(): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`;
}

View File

@@ -0,0 +1,233 @@
/**
* Service API pour interagir avec Radio Paradise
*/
export interface ChannelInfo {
id: number;
name: string;
description: string;
}
export interface SongInfo {
index: number;
artist: string;
title: string;
album: string;
year?: number;
elapsed_ms: number;
duration_ms: number;
cover_url?: string;
rating?: number;
}
export interface BlockResponse {
event: number;
end_event: number;
url: string;
length_ms: number;
songs: SongInfo[];
}
export interface NowPlayingResponse {
event: number;
end_event: number;
stream_url: string;
block_length_ms: number;
current_song_index?: number;
current_song?: SongInfo;
songs: SongInfo[];
}
export interface StreamUrlResponse {
event: number;
stream_url: string;
length_ms: number;
}
export interface CoverUrlResponse {
event: number;
song_index: number;
cover_url?: string;
cover_type: string;
}
export interface ApiError {
error: string;
message: string;
}
/**
* Liste tous les canaux disponibles
*/
export async function listChannels(): Promise<ChannelInfo[]> {
const response = await fetch("/api/radioparadise/channels");
if (!response.ok) {
throw new Error("Failed to fetch channels");
}
return response.json();
}
/**
* Récupère le morceau en cours de lecture
*/
export async function getNowPlaying(channel?: number): Promise<NowPlayingResponse> {
const url = channel !== undefined
? `/api/radioparadise/now-playing?channel=${channel}`
: "/api/radioparadise/now-playing";
const response = await fetch(url);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch now playing");
}
return response.json();
}
/**
* Récupère le block actuel
*/
export async function getCurrentBlock(channel?: number): Promise<BlockResponse> {
const url = channel !== undefined
? `/api/radioparadise/block/current?channel=${channel}`
: "/api/radioparadise/block/current";
const response = await fetch(url);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch current block");
}
return response.json();
}
/**
* Récupère un block spécifique par son event ID
*/
export async function getBlockById(eventId: number, channel?: number): Promise<BlockResponse> {
const url = channel !== undefined
? `/api/radioparadise/block/${eventId}?channel=${channel}`
: `/api/radioparadise/block/${eventId}`;
const response = await fetch(url);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch block");
}
return response.json();
}
/**
* Récupère un morceau spécifique d'un block
*/
export async function getSongByIndex(
eventId: number,
index: number,
channel?: number
): Promise<SongInfo> {
const url = channel !== undefined
? `/api/radioparadise/block/${eventId}/song/${index}?channel=${channel}`
: `/api/radioparadise/block/${eventId}/song/${index}`;
const response = await fetch(url);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch song");
}
return response.json();
}
/**
* Récupère l'URL de la pochette d'un morceau
*/
export async function getCoverUrl(
eventId: number,
songIndex: number,
channel?: number
): Promise<CoverUrlResponse> {
const url = channel !== undefined
? `/api/radioparadise/cover-url/${eventId}/${songIndex}?channel=${channel}`
: `/api/radioparadise/cover-url/${eventId}/${songIndex}`;
const response = await fetch(url);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch cover URL");
}
return response.json();
}
/**
* Récupère l'URL de streaming d'un block
*/
export async function getStreamUrl(
eventId: number,
channel?: number
): Promise<StreamUrlResponse> {
const url = channel !== undefined
? `/api/radioparadise/stream-url/${eventId}?channel=${channel}`
: `/api/radioparadise/stream-url/${eventId}`;
const response = await fetch(url);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || "Failed to fetch stream URL");
}
return response.json();
}
/**
* Formate une durée en millisecondes en format MM:SS
*/
export function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Formate une durée en millisecondes en format H:MM:SS si >= 1h, sinon MM:SS
*/
export function formatDurationLong(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Récupère l'URL de la pochette d'un morceau, avec fallback vers l'image par défaut
*/
export function getSongCoverUrl(song: SongInfo): string | undefined {
return song.cover_url;
}
/**
* Retourne le nom complet d'un canal
*/
export function getChannelName(channelId: number): string {
const channelNames: Record<number, string> = {
0: "Main Mix",
1: "Mellow Mix",
2: "Rock Mix",
3: "Eclectic Mix",
};
return channelNames[channelId] || "Unknown";
}
/**
* Retourne la description d'un canal
*/
export function getChannelDescription(channelId: number): string {
const descriptions: Record<number, string> = {
0: "Eclectic mix of rock, world, electronica, and more",
1: "Mellower, less aggressive music",
2: "Heavier, more guitar-driven music",
3: "Curated worldwide selection",
};
return descriptions[channelId] || "";
}

View File

@@ -447,7 +447,7 @@ impl StreamingFlacSink {
/// Create a sink with a custom broadcast pacing limit and options.
pub fn with_options(
encoder_options: EncoderOptions,
mut encoder_options: EncoderOptions,
bits_per_sample: u8,
broadcast_max_lead_time: f64,
options: StreamingSinkOptions,
@@ -457,6 +457,9 @@ impl StreamingFlacSink {
panic!("bits_per_sample must be 16, 24, or 32");
}
// Transfer server_base_url from StreamingSinkOptions to EncoderOptions
encoder_options.server_base_url = options.server_base_url.clone();
// Create PCM channel (bounded for backpressure)
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(16);

View File

@@ -396,7 +396,7 @@ impl StreamingOggFlacSink {
/// Create a sink with a custom broadcast pacing limit and options.
pub fn with_options(
encoder_options: EncoderOptions,
mut encoder_options: EncoderOptions,
bits_per_sample: u8,
broadcast_max_lead_time: f64,
options: StreamingSinkOptions,
@@ -406,6 +406,9 @@ impl StreamingOggFlacSink {
panic!("bits_per_sample must be 16, 24, or 32");
}
// Transfer server_base_url from StreamingSinkOptions to EncoderOptions
encoder_options.server_base_url = options.server_base_url.clone();
// Create PCM channel (bounded for backpressure)
let (pcm_tx, pcm_rx) = mpsc::channel::<PcmChunk>(16);

View File

@@ -53,6 +53,7 @@ pub struct StreamingSinkOptions {
pub default_title: Option<String>,
pub default_artist: Option<String>,
pub use_only_default_metadata: bool,
pub server_base_url: Option<String>,
}
impl StreamingSinkOptions {
@@ -63,6 +64,7 @@ impl StreamingSinkOptions {
default_title: None,
default_artist: None,
use_only_default_metadata: false,
server_base_url: None,
}
}
@@ -73,6 +75,7 @@ impl StreamingSinkOptions {
default_title: None,
default_artist: None,
use_only_default_metadata: false,
server_base_url: None,
}
}
@@ -100,6 +103,11 @@ impl StreamingSinkOptions {
self.use_only_default_metadata = only_default;
self
}
pub fn with_server_base_url(mut self, url: impl Into<Option<String>>) -> Self {
self.server_base_url = url.into();
self
}
}
/// Shared handle state for streaming sinks.

101
pmoaudiocache/src/api.rs Normal file
View File

@@ -0,0 +1,101 @@
//! API REST handlers spécifiques au cache audio
use crate::metadata_ext::AudioTrackMetadataExt;
use crate::Cache;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use pmometadata::TrackMetadata;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use utoipa::ToSchema;
/// Réponse contenant l'URL de la cover avec fallback
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CoverUrlResponse {
/// PK de la piste
#[schema(example = "1a2b3c4d5e6f7a8b")]
pub pk: String,
/// URL de la cover (cover_pk, cover_url, ou data URL par défaut)
#[schema(example = "https://example.com/cover.jpg")]
pub cover_url: String,
/// Source de l'URL: "cover_pk", "cover_url", ou "default"
#[schema(example = "cover_pk")]
pub source: String,
}
/// Récupère l'URL de la cover d'une piste avec logique de fallback
///
/// Cette route retourne l'URL de la cover en appliquant la logique de priorité suivante :
/// 1. Si `cover_pk` est défini dans les métadonnées, retourne la clé du cache de covers
/// 2. Sinon, si `cover_url` est défini, retourne l'URL externe
/// 3. Sinon, retourne une image SVG par défaut (data URL)
///
/// # Arguments
///
/// * `pk` - Clé primaire de la piste audio
///
/// # Responses
///
/// * `200 OK` - Retourne l'URL de la cover avec la source
/// * `404 NOT_FOUND` - Piste non trouvée
/// * `500 INTERNAL_SERVER_ERROR` - Erreur lors de la lecture des métadonnées
#[utoipa::path(
get,
path = "/{pk}/cover-url",
tag = "audio",
params(
("pk" = String, Path, description = "Clé primaire de la piste")
),
responses(
(status = 200, description = "URL de la cover récupérée avec succès", body = CoverUrlResponse),
(status = 404, description = "Piste non trouvée", body = pmocache::api::ErrorResponse),
(status = 500, description = "Erreur interne", body = pmocache::api::ErrorResponse),
)
)]
pub async fn get_cover_url(
State(cache): State<Arc<Cache>>,
Path(pk): Path<String>,
) -> impl IntoResponse {
// Vérifier que la piste existe
if cache.db.get(&pk, false).is_err() {
return (
StatusCode::NOT_FOUND,
Json(pmocache::api::ErrorResponse {
error: "NOT_FOUND".to_string(),
message: format!("Track with pk '{}' not found in cache", pk),
}),
)
.into_response();
}
// Récupérer les métadonnées
let metadata = cache.track_metadata(&pk);
let metadata_guard = metadata.read().await;
// Déterminer la source et l'URL
let (cover_url, source) = match metadata_guard.get_cover_pk().await {
Ok(Some(cover_pk)) if !cover_pk.is_empty() => (cover_pk, "cover_pk".to_string()),
_ => match metadata_guard.get_cover_url().await {
Ok(Some(url)) if !url.is_empty() => (url, "cover_url".to_string()),
_ => (
pmometadata::get_default_cover_url(),
"default".to_string(),
),
},
};
(
StatusCode::OK,
Json(CoverUrlResponse {
pk,
cover_url,
source,
}),
)
.into_response()
}

View File

@@ -81,6 +81,9 @@ pub mod metadata_ext;
pub mod streaming;
pub mod track_metadata;
#[cfg(feature = "pmoserver")]
pub mod api;
#[cfg(feature = "pmoserver")]
pub mod openapi;
@@ -218,7 +221,19 @@ impl AudioCacheExt for pmoserver::Server {
// API REST générique (pmocache)
// Routes: GET/POST/DELETE /api/audio, etc.
let api_router = create_api_router(cache.clone());
let mut api_router = create_api_router(cache.clone());
// Ajouter les endpoints audio spécifiques
// Route: GET /api/audio/{pk}/cover-url
api_router = api_router.merge(
axum::Router::new()
.route(
"/{pk}/cover-url",
axum::routing::get(crate::api::get_cover_url),
)
.with_state(cache.clone())
);
let openapi = crate::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "audio").await;

View File

@@ -7,6 +7,9 @@ use utoipa::OpenApi;
/// L'API réutilise les handlers génériques de pmocache.
#[derive(OpenApi)]
#[openapi(
paths(
crate::api::get_cover_url,
),
components(
schemas(
pmocache::CacheEntry,
@@ -15,6 +18,7 @@ use utoipa::OpenApi;
pmocache::api::DeleteItemResponse,
pmocache::api::ErrorResponse,
pmocache::api::DownloadStatus,
crate::api::CoverUrlResponse,
)
),
tags(
@@ -56,6 +60,9 @@ Supprime une piste
### GET /api/audio/{pk}/status
Récupère le statut du téléchargement et de la conversion
### GET /api/audio/{pk}/cover-url
Récupère l'URL de la cover avec fallback automatique (cover_pk → cover_url → image par défaut)
### DELETE /api/audio
Purge complètement le cache
@@ -84,6 +91,8 @@ Les métadonnées suivantes sont extraites et stockées :
- Numéro de piste/disque, total de pistes/disques
- Durée, taux d'échantillonnage, bitrate
- Nombre de canaux
- Cover : `cover_pk` (clé dans le cache de covers) et `cover_url` (URL externe)
- Fallback automatique vers une image SVG par défaut si aucune cover n'est disponible
## Collections

View File

@@ -98,6 +98,9 @@ struct ExtractedMetadata {
year: Option<u32>,
genre: Option<String>,
track_number: Option<u32>,
cover_pk: Option<String>,
cover_url: Option<String>,
server_base_url: Option<String>,
}
/// Options for configuring FLAC encoding.
@@ -121,6 +124,10 @@ pub struct EncoderOptions {
/// Metadata to embed in the FLAC file (Vorbis Comments).
/// Default: None (no metadata)
pub metadata: Option<Arc<RwLock<dyn pmometadata::TrackMetadata>>>,
/// Base URL of the server for constructing cover URLs.
/// Default: None
pub server_base_url: Option<String>,
}
impl Default for EncoderOptions {
@@ -131,6 +138,7 @@ impl Default for EncoderOptions {
total_samples: None,
block_size: None,
metadata: None,
server_base_url: None,
}
}
}
@@ -251,6 +259,8 @@ where
let artist = metadata.get_artist().await.ok().flatten();
let album = metadata.get_album().await.ok().flatten();
let year = metadata.get_year().await.ok().flatten();
let cover_pk = metadata.get_cover_pk().await.ok().flatten();
let cover_url = metadata.get_cover_url().await.ok().flatten();
// Try to extract genre and track_number from extra fields
let extra = metadata.get_extra().await.ok().flatten();
@@ -266,6 +276,9 @@ where
year,
genre,
track_number,
cover_pk,
cover_url,
server_base_url: options.server_base_url.clone(),
})
} else {
None
@@ -453,6 +466,13 @@ unsafe fn setup_metadata(
if let Some(track_number) = metadata.track_number {
append_comment("TRACKNUMBER", &track_number.to_string())?;
}
// Construct cover URL: use cover_pk with server_base_url if available, fallback to cover_url
if let (Some(ref pk), Some(ref base_url)) = (&metadata.cover_pk, &metadata.server_base_url) {
let cover_url = format!("{}/covers/image/{}", base_url, pk);
append_comment("COVERART", &cover_url)?;
} else if let Some(cover_url) = &metadata.cover_url {
append_comment("COVERART", cover_url)?;
}
// Set the metadata on the encoder
let mut metadata_array = [meta];

View File

@@ -11,6 +11,7 @@
//! - **Error handling**: Distinguishes between transient errors (NotImplemented, ReadOnly)
//! and backend errors that should be propagated
//! - **Metadata copying**: Helper function to copy metadata between implementations
//! - **Fallback cover**: Provides a default cover image when none is available
//!
//! # Examples
//!
@@ -40,6 +41,60 @@ use std::{
};
use tokio::sync::RwLock;
/// Image SVG par défaut pour les covers manquantes.
///
/// Cette constante contient un SVG élégant représentant une note de musique,
/// utilisé comme fallback quand aucune cover n'est disponible.
///
/// # Utilisation
///
/// ```rust
/// use pmometadata::DEFAULT_COVER_SVG;
///
/// // Utiliser comme data URL
/// let data_url = format!("data:image/svg+xml;utf8,{}", DEFAULT_COVER_SVG);
/// ```
pub const DEFAULT_COVER_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgGrad)"/>
<g transform="translate(200, 200)">
<circle cx="0" cy="20" r="35" fill="white" opacity="0.9"/>
<ellipse cx="0" cy="20" rx="35" ry="10" fill="white" opacity="0.3"/>
<rect x="-6" y="-120" width="12" height="140" rx="6" fill="white" opacity="0.9"/>
<path d="M 6,-115 Q 45,-105 45,-70 Q 45,-40 20,-35"
fill="none" stroke="white" stroke-width="12"
stroke-linecap="round" opacity="0.9"/>
</g>
<text x="200" y="360" text-anchor="middle"
font-family="system-ui, -apple-system, sans-serif"
font-size="20" fill="white" opacity="0.6">
No Cover Available
</text>
</svg>"#;
/// Retourne l'URL de la cover par défaut comme data URL.
///
/// Cette fonction peut être étendue à l'avenir pour accepter des paramètres
/// de personnalisation (taille, couleur, etc.).
///
/// # Exemples
///
/// ```rust
/// use pmometadata::get_default_cover_url;
///
/// let url = get_default_cover_url();
/// // url commence par "data:image/svg+xml;utf8,<svg..."
/// assert!(url.starts_with("data:image/svg+xml"));
/// ```
pub fn get_default_cover_url() -> String {
format!("data:image/svg+xml;utf8,{}", DEFAULT_COVER_SVG)
}
/// Helper macro for copying a single metadata field.
macro_rules! copy_a_metadata {
($src:ident, $dest:ident, $key:ident) => {
@@ -322,6 +377,105 @@ pub trait TrackMetadata: Send + Sync {
Err(MetadataError::NotImplemented)
}
/// Retourne l'URL de la cover avec logique de fallback.
///
/// Cette méthode implémente la logique de priorité suivante :
/// 1. Si `cover_pk` est défini, retourne `Some(cover_pk)` (pour utilisation avec le cover cache)
/// 2. Sinon, si `cover_url` est défini, retourne `Some(cover_url)` (URL externe)
/// 3. Sinon, retourne `None`
///
/// Si vous voulez toujours obtenir une URL (avec image par défaut),
/// utilisez [`get_cover_url_or_default`] à la place.
///
/// Les implémentations peuvent overrider cette méthode si elles veulent un comportement
/// différent, mais l'implémentation par défaut devrait convenir à la plupart des cas.
///
/// # Exemples
///
/// ```rust
/// use pmometadata::{TrackMetadata, MemoryTrackMetadata};
///
/// # tokio_test::block_on(async {
/// let mut metadata = MemoryTrackMetadata::new();
///
/// // Cas 1: cover_pk défini (prioritaire)
/// metadata.set_cover_pk(Some("abc123".to_string())).await.unwrap();
/// metadata.set_cover_url(Some("https://example.com/cover.jpg".to_string())).await.unwrap();
/// assert_eq!(
/// metadata.get_cover_url_with_fallback().await.unwrap(),
/// Some("abc123".to_string())
/// );
///
/// // Cas 2: seulement cover_url
/// let mut metadata2 = MemoryTrackMetadata::new();
/// metadata2.set_cover_url(Some("https://example.com/cover.jpg".to_string())).await.unwrap();
/// assert_eq!(
/// metadata2.get_cover_url_with_fallback().await.unwrap(),
/// Some("https://example.com/cover.jpg".to_string())
/// );
///
/// // Cas 3: aucune cover
/// let metadata3 = MemoryTrackMetadata::new();
/// assert_eq!(metadata3.get_cover_url_with_fallback().await.unwrap(), None);
/// # });
/// ```
async fn get_cover_url_with_fallback(&self) -> MetadataResult<String> {
// Priorité 1: cover_pk (cache local)
if let Ok(Some(pk)) = self.get_cover_pk().await {
if !pk.is_empty() {
return Ok(Some(pk));
}
}
// Priorité 2: cover_url (URL externe)
if let Ok(Some(url)) = self.get_cover_url().await {
if !url.is_empty() {
return Ok(Some(url));
}
}
// Aucune cover disponible
Ok(None)
}
/// Retourne l'URL de la cover ou l'image par défaut.
///
/// Contrairement à [`get_cover_url_with_fallback`], cette méthode retourne **toujours**
/// une URL utilisable, en fournissant une image SVG par défaut si aucune cover n'est disponible.
///
/// La logique de priorité est :
/// 1. `cover_pk` (cache local)
/// 2. `cover_url` (URL externe)
/// 3. Image SVG par défaut (data URL)
///
/// # Exemples
///
/// ```rust
/// use pmometadata::{TrackMetadata, MemoryTrackMetadata};
///
/// # tokio_test::block_on(async {
/// // Sans cover : retourne l'image par défaut
/// let metadata = MemoryTrackMetadata::new();
/// let url = metadata.get_cover_url_or_default().await.unwrap();
/// assert!(url.starts_with("data:image/svg+xml"));
///
/// // Avec cover : retourne la cover
/// let mut metadata2 = MemoryTrackMetadata::new();
/// metadata2.set_cover_pk(Some("abc123".to_string())).await.unwrap();
/// assert_eq!(
/// metadata2.get_cover_url_or_default().await.unwrap(),
/// "abc123"
/// );
/// # });
/// ```
async fn get_cover_url_or_default(&self) -> Result<String, MetadataError> {
match self.get_cover_url_with_fallback().await {
Ok(Some(url)) => Ok(url),
Ok(None) => Ok(get_default_cover_url()),
Err(e) => Err(e),
}
}
async fn get_extra(&self) -> MetadataResult<HashMap<String, String>> {
Err(MetadataError::NotImplemented)
}
@@ -1057,4 +1211,107 @@ mod tests {
Some("Artist".to_string())
);
}
#[tokio::test]
async fn test_get_cover_url_with_fallback_cover_pk_priority() {
let mut metadata = MemoryTrackMetadata::new();
metadata
.set_cover_pk(Some("abc123".to_string()))
.await
.unwrap();
metadata
.set_cover_url(Some("https://example.com/cover.jpg".to_string()))
.await
.unwrap();
// cover_pk should have priority
assert_eq!(
metadata.get_cover_url_with_fallback().await.unwrap(),
Some("abc123".to_string())
);
}
#[tokio::test]
async fn test_get_cover_url_with_fallback_cover_url_only() {
let mut metadata = MemoryTrackMetadata::new();
metadata
.set_cover_url(Some("https://example.com/cover.jpg".to_string()))
.await
.unwrap();
assert_eq!(
metadata.get_cover_url_with_fallback().await.unwrap(),
Some("https://example.com/cover.jpg".to_string())
);
}
#[tokio::test]
async fn test_get_cover_url_with_fallback_none() {
let metadata = MemoryTrackMetadata::new();
assert_eq!(
metadata.get_cover_url_with_fallback().await.unwrap(),
None
);
}
#[tokio::test]
async fn test_get_cover_url_with_fallback_empty_strings() {
let mut metadata = MemoryTrackMetadata::new();
metadata.set_cover_pk(Some("".to_string())).await.unwrap();
metadata
.set_cover_url(Some("https://example.com/cover.jpg".to_string()))
.await
.unwrap();
// Empty cover_pk should fallback to cover_url
assert_eq!(
metadata.get_cover_url_with_fallback().await.unwrap(),
Some("https://example.com/cover.jpg".to_string())
);
}
#[tokio::test]
async fn test_get_cover_url_or_default_with_cover_pk() {
let mut metadata = MemoryTrackMetadata::new();
metadata
.set_cover_pk(Some("abc123".to_string()))
.await
.unwrap();
assert_eq!(
metadata.get_cover_url_or_default().await.unwrap(),
"abc123"
);
}
#[tokio::test]
async fn test_get_cover_url_or_default_with_cover_url() {
let mut metadata = MemoryTrackMetadata::new();
metadata
.set_cover_url(Some("https://example.com/cover.jpg".to_string()))
.await
.unwrap();
assert_eq!(
metadata.get_cover_url_or_default().await.unwrap(),
"https://example.com/cover.jpg"
);
}
#[tokio::test]
async fn test_get_cover_url_or_default_no_cover() {
let metadata = MemoryTrackMetadata::new();
let url = metadata.get_cover_url_or_default().await.unwrap();
// Should return the default SVG data URL
assert!(url.starts_with("data:image/svg+xml"));
assert!(url.contains("<svg"));
}
#[tokio::test]
async fn test_default_cover_url() {
let url = get_default_cover_url();
assert!(url.starts_with("data:image/svg+xml;utf8,<svg"));
assert!(url.contains("No Cover Available"));
}
}

View File

@@ -9,13 +9,18 @@
//! defaults to the “main” mix.
use axum::{
body::Body, extract::State, http::StatusCode, response::Response, routing::get, Router,
body::Body,
extract::{Path, Request, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use pmoaudiocache::{
new_cache_with_consolidation as new_audio_cache,
register_audio_cache as register_global_audio_cache,
};
use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache};
use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache};
use pmoparadise::{
channels::{ChannelDescriptor, ALL_CHANNELS},
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
@@ -31,6 +36,7 @@ use tracing::info;
struct AppState {
channel: Arc<ParadiseStreamChannel>,
descriptor: ChannelDescriptor,
cover_cache: Arc<CoverCache>,
}
#[tokio::main]
@@ -64,21 +70,26 @@ async fn main() -> anyhow::Result<()> {
let history_opts = history_builder.build_for_channel(&descriptor).await?;
let mut channel_config = ParadiseStreamChannelConfig::default();
// Base URL for cover images in stream metadata
let server_base_url = "http://localhost:8080".to_string();
// Configuration commune pour FLAC et OGG
let common_options = StreamingSinkOptions::flac_defaults()
.with_default_artist(Some("Radio Paradise".to_string()))
.with_default_title(descriptor.display_name.to_string());
.with_default_title(descriptor.display_name.to_string())
.with_server_base_url(Some(server_base_url.clone()));
channel_config.flac_options = common_options.clone();
channel_config.ogg_options = StreamingSinkOptions::ogg_defaults()
.with_default_artist(Some("Radio Paradise".to_string()))
.with_default_title(descriptor.display_name.to_string());
.with_default_title(descriptor.display_name.to_string())
.with_server_base_url(Some(server_base_url));
let channel = Arc::new(
ParadiseStreamChannel::new(
descriptor,
channel_config,
Some(cover_cache),
Some(cover_cache.clone()),
Some(history_opts),
)
.await?,
@@ -87,17 +98,27 @@ async fn main() -> anyhow::Result<()> {
let state = AppState {
channel,
descriptor,
cover_cache,
};
let app = Router::new()
.route("/stream/flac", get(stream_flac))
.route("/stream/ogg", get(stream_ogg))
.route("/metadata", get(get_metadata))
.route("/covers/image/{pk}", get(get_cover))
.with_state(state);
let addr: SocketAddr = ([0, 0, 0, 0], 8080).into();
info!("HTTP server listening on http://{addr}/stream/flac and /stream/ogg");
info!("Connect with a FLAC player (e.g. ffplay http://localhost:8080/stream/flac)");
info!("Connect with an OGG/OGG-FLAC player (e.g. ffplay http://localhost:8080/stream/ogg)");
info!("========================================");
info!("HTTP server listening on http://{addr}");
info!("Available endpoints:");
info!(" - /stream/flac : FLAC audio stream");
info!(" - /stream/ogg : OGG-FLAC audio stream");
info!(" - /metadata : Current track metadata (JSON)");
info!(" - /covers/image/{{pk}} : Album cover images (WebP)");
info!("========================================");
info!("Connect with a FLAC player: ffplay http://localhost:8080/stream/flac");
info!("Connect with an OGG-FLAC player: ffplay http://localhost:8080/stream/ogg");
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app.into_make_service()).await?;
@@ -139,6 +160,74 @@ async fn stream_ogg(State(state): State<AppState>) -> Result<Response, StatusCod
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
async fn get_metadata(
State(state): State<AppState>,
request: Request,
) -> Result<impl IntoResponse, StatusCode> {
let mut metadata = state.channel.metadata().await;
// Si cover_pk est disponible, construire l'URL complète depuis les headers
// Format: /covers/image/{pk} (correspond à la structure du cache pmocovers)
if let Some(ref pk) = metadata.cover_pk {
let base_url = extract_base_url(&request);
metadata.cover_url = Some(format!("{}/covers/image/{}", base_url, pk));
}
Ok(Json(metadata))
}
/// Extrait l'URL de base depuis les headers HTTP de la requête
/// Supporte les proxies avec X-Forwarded-Host et X-Forwarded-Proto
fn extract_base_url(request: &Request) -> String {
let headers = request.headers();
// Déterminer le schéma (http ou https)
let scheme = headers
.get("x-forwarded-proto")
.and_then(|h| h.to_str().ok())
.unwrap_or("http");
// Déterminer le host
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost:8080");
format!("{}://{}", scheme, host)
}
async fn get_cover(
State(state): State<AppState>,
Path(pk): Path<String>,
) -> Result<Response, StatusCode> {
// Récupérer le chemin de la cover depuis le cache
// Le cache retourne un PathBuf pointant vers le fichier .webp
let cover_path = state
.cover_cache
.get(&pk)
.await
.map_err(|e| {
tracing::error!("Failed to get cover path for {}: {}", pk, e);
StatusCode::NOT_FOUND
})?;
// Lire le fichier
let cover_data = tokio::fs::read(&cover_path)
.await
.map_err(|e| {
tracing::error!("Failed to read cover file {:?}: {}", cover_path, e);
StatusCode::NOT_FOUND
})?;
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "image/webp")
.header("Cache-Control", "public, max-age=86400")
.body(Body::from(cover_data))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
fn pick_descriptor(arg: Option<String>) -> anyhow::Result<ChannelDescriptor> {
if let Some(token) = arg {
if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.slug == token) {

View File

@@ -141,6 +141,37 @@ pub struct BlockResponse {
pub songs: Vec<SongInfo>,
}
/// Réponse pour l'URL de streaming
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct StreamUrlResponse {
/// Event ID du block
#[schema(example = 1234567)]
pub event: u64,
/// URL de streaming FLAC
#[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")]
pub stream_url: String,
/// Durée totale (ms)
#[schema(example = 900000)]
pub length_ms: u64,
}
/// Réponse pour l'URL de pochette
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CoverUrlResponse {
/// Event ID du block
#[schema(example = 1234567)]
pub event: u64,
/// Index du morceau
#[schema(example = 0)]
pub song_index: usize,
/// URL de la pochette (résolution complète)
#[schema(example = "https://img.radioparadise.com/covers/l/B00000I0JF.jpg")]
pub cover_url: Option<String>,
/// Type de pochette: "cover" (petite) ou "cover_large" (grande)
#[schema(example = "cover_large")]
pub cover_type: String,
}
impl From<Block> for BlockResponse {
fn from(block: Block) -> Self {
let songs = block
@@ -313,25 +344,223 @@ async fn get_channels() -> Json<Vec<ChannelInfo>> {
Json(channels)
}
/// GET /block/{event_id}/song/{index} - Récupère un morceau spécifique d'un block
#[utoipa::path(
get,
path = "/block/{event_id}/song/{index}",
params(
("event_id" = u64, Path, description = "Event ID du block"),
("index" = usize, Path, description = "Index du morceau (0-based)"),
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "Morceau demandé", body = SongInfo),
(status = 404, description = "Morceau non trouvé"),
(status = 500, description = "Erreur serveur")
),
tag = "Radio Paradise"
)]
async fn get_song_by_index(
State(state): State<RadioParadiseState>,
Path((event_id, index)): Path<(u64, usize)>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<SongInfo>, StatusCode> {
let client = state.client_for_params(&params).await?;
let block = client.get_block(Some(event_id)).await.map_err(|e| {
tracing::error!(
"Failed to fetch block {} from Radio Paradise: {}",
event_id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let song = block.get_song(index).ok_or_else(|| {
tracing::warn!("Song index {} not found in block {}", index, event_id);
StatusCode::NOT_FOUND
})?;
let song_info = SongInfo {
index,
artist: song.artist.clone(),
title: song.title.clone(),
album: song.album.clone().unwrap_or_default(),
year: song.year,
elapsed_ms: song.elapsed,
duration_ms: song.duration,
cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)),
rating: song.rating,
};
Ok(Json(song_info))
}
/// GET /cover-url/{event_id}/{song_index} - Récupère l'URL de la pochette d'un morceau
///
/// Utilise automatiquement cover_large si disponible, sinon cover en fallback
#[utoipa::path(
get,
path = "/cover-url/{event_id}/{song_index}",
params(
("event_id" = u64, Path, description = "Event ID du block"),
("song_index" = usize, Path, description = "Index du morceau (0-based)"),
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse),
(status = 404, description = "Morceau non trouvé"),
(status = 500, description = "Erreur serveur")
),
tag = "Radio Paradise"
)]
async fn get_cover_url(
State(state): State<RadioParadiseState>,
Path((event_id, song_index)): Path<(u64, usize)>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<CoverUrlResponse>, StatusCode> {
let client = state.client_for_params(&params).await?;
let block = client.get_block(Some(event_id)).await.map_err(|e| {
tracing::error!(
"Failed to fetch block {} from Radio Paradise: {}",
event_id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let song = block.get_song(song_index).ok_or_else(|| {
tracing::warn!(
"Song index {} not found in block {}",
song_index,
event_id
);
StatusCode::NOT_FOUND
})?;
// Fallback: cover_large → cover → none
let (cover_url, cover_type) = if let Some(ref cover_large) = song.cover_large {
(block.cover_url(cover_large), "cover_large")
} else if let Some(ref cover) = song.cover {
(block.cover_url(cover), "cover")
} else {
(None, "none")
};
Ok(Json(CoverUrlResponse {
event: event_id,
song_index,
cover_url,
cover_type: cover_type.to_string(),
}))
}
/// GET /stream-url/{event_id} - Récupère l'URL de streaming direct d'un block
#[utoipa::path(
get,
path = "/stream-url/{event_id}",
params(
("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"),
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "URL de streaming", body = StreamUrlResponse),
(status = 500, description = "Erreur serveur")
),
tag = "Radio Paradise"
)]
async fn get_stream_url(
State(state): State<RadioParadiseState>,
Path(event_id): Path<u64>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<StreamUrlResponse>, StatusCode> {
let client = state.client_for_params(&params).await?;
let block = client.get_block(Some(event_id)).await.map_err(|e| {
tracing::error!(
"Failed to fetch block {} from Radio Paradise: {}",
event_id,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(StreamUrlResponse {
event: block.event,
stream_url: block.url,
length_ms: block.length,
}))
}
/// Documentation OpenAPI pour l'API Radio Paradise
#[derive(OpenApi)]
#[openapi(
info(
title = "Radio Paradise API",
version = "1.0.0",
description = "API REST pour accéder aux métadonnées de Radio Paradise"
description = r#"
# API REST pour Radio Paradise
Cette API permet d'accéder aux métadonnées et flux de Radio Paradise.
## Fonctionnalités
- **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks
- **Multi-canaux** : Support des 4 canaux Radio Paradise (Main, Mellow, Rock, Eclectic)
- **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité
- **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille)
- **Historique** : Accès aux blocks passés via event_id
## Canaux disponibles
- **0: Main Mix** - Eclectic mix of rock, world, electronica, and more
- **1: Mellow Mix** - Mellower, less aggressive music
- **2: Rock Mix** - Heavier, more guitar-driven music
- **3: Eclectic Mix** - Curated worldwide selection
## Format des données
### Blocks
Les blocks sont des fichiers FLAC continus contenant plusieurs morceaux.
Chaque block a un `event` (ID de début) et `end_event` (ID du prochain block).
### Timing
- Tous les temps sont en millisecondes (ms)
- `elapsed_ms` : temps écoulé depuis le début du block
- `duration_ms` : durée du morceau
## Exemples d'utilisation
### Récupérer le morceau en cours
```
GET /api/radioparadise/now-playing?channel=0
```
### Récupérer un block spécifique
```
GET /api/radioparadise/block/1234567?channel=0
```
### Récupérer la pochette d'un morceau (avec fallback automatique)
```
GET /api/radioparadise/cover-url/1234567/0?channel=0
```
"#
),
paths(
get_now_playing,
get_current_block,
get_block_by_id,
get_channels
get_channels,
get_song_by_index,
get_cover_url,
get_stream_url
),
components(schemas(
NowPlayingResponse,
BlockResponse,
SongInfo,
ChannelInfo
ChannelInfo,
StreamUrlResponse,
CoverUrlResponse
)),
tags(
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise")
@@ -345,6 +574,9 @@ pub fn create_api_router(state: RadioParadiseState) -> Router {
.route("/now-playing", get(get_now_playing))
.route("/block/current", get(get_current_block))
.route("/block/{event_id}", get(get_block_by_id))
.route("/block/{event_id}/song/{index}", get(get_song_by_index))
.route("/cover-url/{event_id}/{song_index}", get(get_cover_url))
.route("/stream-url/{event_id}", get(get_stream_url))
.route("/channels", get(get_channels))
.with_state(state)
}