update la webapp
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
233
pmoapp/webapp/src/services/radioParadise.ts
Normal file
233
pmoapp/webapp/src/services/radioParadise.ts
Normal 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] || "";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
101
pmoaudiocache/src/api.rs
Normal 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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(¶ms).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(¶ms).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(¶ms).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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user