Implémentation du seek audio avec support des Range requests

Ajout de la fonctionnalité de seek audio dans les renderers avec support des Range requests HTTP pour permettre le seek pendant le téléchargement.

- Ajout de la route /control/renderers/{renderer_id}/seek pour le seek par secondes
- Implémentation du support des Range requests dans le serveur de fichiers (pmocache)
- Mise à jour des dépendances avec http-range-header, tower, tower-http
- Amélioration de l'interface utilisateur avec une barre de progression interactive dans CurrentTrack.vue
- Support du seek pendant le téléchargement avec streaming progressif
- Gestion des erreurs et timeouts pour les commandes de seek
This commit is contained in:
2026-01-10 15:08:44 +01:00
parent 56e1f4c0fb
commit c745179200
9 changed files with 982 additions and 512 deletions

18
Cargo.lock generated
View File

@@ -2084,6 +2084,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -3677,6 +3683,8 @@ dependencies = [
"tempfile",
"tokio",
"tokio-util",
"tower 0.5.2",
"tower-http",
"tracing",
"utoipa",
]
@@ -5844,14 +5852,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.10.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]

View File

@@ -1,78 +1,201 @@
<script setup lang="ts">
import { computed, toRef, ref } from 'vue'
import { useRenderer } from '@/composables/useRenderers'
import { Music, X } from 'lucide-vue-next'
import { computed, toRef, ref } from "vue";
import { useRenderer } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui";
import { api } from "@/services/pmocontrol/api";
import { Music, X } from "lucide-vue-next";
const props = defineProps<{
rendererId: string
}>()
rendererId: string;
}>();
const { state } = useRenderer(toRef(props, 'rendererId'))
const metadata = computed(() => state.value?.current_track)
const { state } = useRenderer(toRef(props, "rendererId"));
const uiStore = useUIStore();
const metadata = computed(() => state.value?.current_track);
const isSeeking = ref(false);
const isDragging = ref(false);
const dragProgress = ref(0);
const seekTargetMs = ref<number | null>(null);
// État pour l'overlay de la cover
const showCoverOverlay = ref(false)
const showMetadata = ref(false)
const showCoverOverlay = ref(false);
const showMetadata = ref(false);
function openCoverOverlay() {
if (hasCover.value) {
showCoverOverlay.value = true
showMetadata.value = false // Reset metadata visibility when opening
showCoverOverlay.value = true;
showMetadata.value = false; // Reset metadata visibility when opening
}
}
function closeCoverOverlay() {
showCoverOverlay.value = false
showMetadata.value = false
showCoverOverlay.value = false;
showMetadata.value = false;
}
// Détecter le clic sur la partie basse de l'image
function handleOverlayContentClick(event: MouseEvent) {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const clickY = event.clientY - rect.top
const height = rect.height
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const clickY = event.clientY - rect.top;
const height = rect.height;
// Si le clic est dans le tiers inférieur, toggle les métadonnées
if (clickY > height * 0.66) {
showMetadata.value = !showMetadata.value
showMetadata.value = !showMetadata.value;
}
}
// Calcul du pourcentage de progression
const progressPercent = computed(() => {
const position = state.value?.position_ms
const duration = state.value?.duration_ms
if (position && duration && duration > 0) {
return (position / duration) * 100
// Si on est en train de drag, utiliser la position du drag
if (isDragging.value) {
return dragProgress.value;
}
return 0
})
// Si on a une cible de seek active
if (seekTargetMs.value !== null) {
const currentPosition = state.value?.position_ms || 0;
// Si la position actuelle est proche de la cible (tolérance de 2 secondes)
if (Math.abs(currentPosition - seekTargetMs.value) < 2000) {
// On peut libérer le contrôle
seekTargetMs.value = null;
dragProgress.value = 0;
} else {
// Sinon, continuer à afficher la position cible
return dragProgress.value;
}
}
const position = state.value?.position_ms;
const duration = state.value?.duration_ms;
if (position && duration && duration > 0) {
return (position / duration) * 100;
}
return 0;
});
// Formater la durée en MM:SS
function formatTime(ms: number | null | undefined): string {
if (!ms) return '--:--'
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
if (!ms) return "--:--";
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
const currentTime = computed(() => formatTime(state.value?.position_ms))
const totalTime = computed(() => formatTime(state.value?.duration_ms))
const currentTime = computed(() => formatTime(state.value?.position_ms));
const totalTime = computed(() => formatTime(state.value?.duration_ms));
const hasCover = computed(() => !!metadata.value?.album_art_uri)
const hasCover = computed(() => !!metadata.value?.album_art_uri);
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
const img = event.target as HTMLImageElement;
img.style.display = "none";
}
// Calculer le pourcentage à partir d'une position X dans la barre
function calculateProgressFromX(
clientX: number,
progressBar: HTMLElement,
): number {
const rect = progressBar.getBoundingClientRect();
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
}
// Gestion du drag sur la progress bar
function handleProgressBarMouseDown(event: MouseEvent) {
const duration = state.value?.duration_ms;
if (!duration || duration === 0 || isSeeking.value) return;
const progressBar = event.currentTarget as HTMLElement;
isDragging.value = true;
dragProgress.value = calculateProgressFromX(event.clientX, progressBar);
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return;
dragProgress.value = calculateProgressFromX(e.clientX, progressBar);
};
const handleMouseUp = async (e: MouseEvent) => {
if (!isDragging.value) return;
const finalPercent = calculateProgressFromX(e.clientX, progressBar);
const newPositionMs = (finalPercent / 100) * duration;
const newPositionSeconds = Math.floor(newPositionMs / 1000);
// Garder dragProgress à jour et définir la cible
dragProgress.value = finalPercent;
seekTargetMs.value = newPositionMs;
// D'abord arrêter le drag pour éviter les mouvements pendant le seek
isDragging.value = false;
try {
isSeeking.value = true;
await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) {
uiStore.notifyError(
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
// En cas d'erreur, réinitialiser
seekTargetMs.value = null;
dragProgress.value = 0;
} finally {
isSeeking.value = false;
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
// Gestion du seek sur la progress bar (clic simple)
async function handleProgressBarClick(event: MouseEvent) {
// Ne rien faire si on vient de drag (géré par mouseup)
if (isDragging.value) return;
const duration = state.value?.duration_ms;
if (!duration || duration === 0 || isSeeking.value) return;
const progressBar = event.currentTarget as HTMLElement;
const percent = calculateProgressFromX(event.clientX, progressBar);
const newPositionMs = (percent / 100) * duration;
const newPositionSeconds = Math.floor(newPositionMs / 1000);
// Définir la cible et la position visuelle
dragProgress.value = percent;
seekTargetMs.value = newPositionMs;
try {
isSeeking.value = true;
await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) {
uiStore.notifyError(
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
// En cas d'erreur, réinitialiser
seekTargetMs.value = null;
dragProgress.value = 0;
} finally {
isSeeking.value = false;
}
}
</script>
<template>
<div class="current-track">
<!-- Cover Art -->
<div class="cover-container" :class="{ clickable: hasCover }" @click="openCoverOverlay">
<div
class="cover-container"
:class="{ clickable: hasCover }"
@click="openCoverOverlay"
>
<img
v-if="hasCover"
:src="metadata?.album_art_uri!"
@@ -88,18 +211,25 @@ function handleImageError(event: Event) {
<!-- Metadata -->
<div class="metadata">
<h2 class="title">{{ metadata?.title || 'Aucun titre' }}</h2>
<p class="artist">{{ metadata?.artist || 'Artiste inconnu' }}</p>
<h2 class="title">{{ metadata?.title || "Aucun titre" }}</h2>
<p class="artist">{{ metadata?.artist || "Artiste inconnu" }}</p>
<p class="album" v-if="metadata?.album">{{ metadata.album }}</p>
</div>
<!-- Progress Bar -->
<div class="progress-section">
<div class="progress-bar">
<div
class="progress-bar"
@click="handleProgressBarClick"
@mousedown="handleProgressBarMouseDown"
:class="{ seeking: isSeeking, dragging: isDragging }"
>
<div
class="progress-bar-fill"
:style="{ width: `${progressPercent}%` }"
></div>
>
<div class="progress-bar-thumb"></div>
</div>
</div>
<div class="time-display">
<span>{{ currentTime }}</span>
@@ -110,9 +240,20 @@ function handleImageError(event: Event) {
<!-- Overlay cover en grand avec effet glassmorphism -->
<Teleport to="body">
<Transition name="cover-overlay">
<div v-if="showCoverOverlay" class="cover-overlay" @click="closeCoverOverlay">
<div class="cover-overlay-content" @click.stop="handleOverlayContentClick">
<button class="cover-overlay-close" @click="closeCoverOverlay" title="Fermer">
<div
v-if="showCoverOverlay"
class="cover-overlay"
@click="closeCoverOverlay"
>
<div
class="cover-overlay-content"
@click.stop="handleOverlayContentClick"
>
<button
class="cover-overlay-close"
@click="closeCoverOverlay"
title="Fermer"
>
<X :size="24" />
</button>
<img
@@ -122,10 +263,15 @@ function handleImageError(event: Event) {
class="cover-overlay-image"
/>
<Transition name="metadata-fade">
<div v-if="metadata && showMetadata" class="cover-overlay-metadata">
<div
v-if="metadata && showMetadata"
class="cover-overlay-metadata"
>
<h2>{{ metadata.title }}</h2>
<p class="artist">{{ metadata.artist }}</p>
<p v-if="metadata.album" class="album">{{ metadata.album }}</p>
<p v-if="metadata.album" class="album">
{{ metadata.album }}
</p>
</div>
</Transition>
</div>
@@ -151,7 +297,9 @@ function handleImageError(event: Event) {
overflow: hidden;
background-color: var(--color-bg-secondary);
box-shadow: var(--shadow-lg);
transition: transform 0.3s ease, box-shadow 0.3s ease;
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
}
.cover-container.clickable {
@@ -210,6 +358,98 @@ function handleImageError(event: Event) {
gap: var(--spacing-xs);
}
.progress-bar {
position: relative;
width: 100%;
height: 6px;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0.25) 50%,
rgba(255, 255, 255, 0.15) 100%
);
border-radius: 3px;
cursor: pointer;
transition: background 0.2s ease;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
overflow: visible;
}
.progress-bar:hover {
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0.2) 100%
);
}
.progress-bar.dragging {
cursor: grabbing;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0.2) 100%
);
}
.progress-bar.seeking {
cursor: wait;
opacity: 0.7;
}
.progress-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #059669 0%, #10b981 50%, #34d399 100%);
border-radius: 3px;
transition: width 0.1s linear;
z-index: 1;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
overflow: visible;
}
.progress-bar.dragging .progress-bar-fill {
transition: width 0s;
box-shadow: 0 0 12px rgba(16, 185, 129, 0.8);
}
.progress-bar-thumb {
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
background: white;
border: 3px solid rgba(0, 0, 0, 0.8);
border-radius: 50%;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.6),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
z-index: 2;
cursor: grab;
}
.progress-bar:hover .progress-bar-thumb {
transform: translateY(-50%) scale(1.2);
box-shadow:
0 3px 12px rgba(0, 0, 0, 0.7),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.progress-bar.dragging .progress-bar-thumb {
transform: translateY(-50%) scale(1.3);
cursor: grabbing;
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.8),
inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.time-display {
display: flex;
justify-content: space-between;
@@ -346,7 +586,7 @@ function handleImageError(event: Event) {
}
.cover-overlay-close:active {
transform: scale(1.0) rotate(90deg);
transform: scale(1) rotate(90deg);
}
@media (prefers-color-scheme: dark) {

View File

@@ -1,50 +1,62 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useRenderer, useRenderers } from '@/composables/useRenderers'
import { useUIStore } from '@/stores/ui'
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
import { computed, toRef } from "vue";
import { useRenderer, useRenderers } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui";
import { Play, Pause, Square, SkipForward } from "lucide-vue-next";
const props = defineProps<{
rendererId: string
}>()
rendererId: string;
}>();
const { state } = useRenderer(toRef(props, 'rendererId'))
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers()
const uiStore = useUIStore()
const { state } = useRenderer(toRef(props, "rendererId"));
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers();
const uiStore = useUIStore();
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
const isPlaying = computed(() => state.value?.transport_state === "PLAYING");
const isPaused = computed(() => state.value?.transport_state === "PAUSED");
const isStopped = computed(
() =>
state.value?.transport_state === "STOPPED" ||
state.value?.transport_state === "NO_MEDIA",
);
async function handlePlay() {
try {
await resumeOrPlayFromQueue(props.rendererId)
await resumeOrPlayFromQueue(props.rendererId);
} catch (error) {
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
uiStore.notifyError(
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
async function handlePause() {
try {
await pause(props.rendererId)
await pause(props.rendererId);
} catch (error) {
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
uiStore.notifyError(
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
async function handleStop() {
try {
await stop(props.rendererId)
await stop(props.rendererId);
} catch (error) {
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
uiStore.notifyError(
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
async function handleNext() {
try {
await next(props.rendererId)
await next(props.rendererId);
} catch (error) {
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
uiStore.notifyError(
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
</script>

View File

@@ -168,6 +168,20 @@ class PMOControlAPI {
);
}
/**
* Seek à une position spécifique (en secondes)
* POST /api/control/renderers/{id}/seek
*/
async seekTo(id: string, seconds: number): Promise<SuccessResponse> {
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(id)}/seek`,
{
method: "POST",
body: JSON.stringify({ seconds }),
},
);
}
/**
* Saute à un index spécifique dans la queue
* POST /api/control/renderers/{id}/queue/seek

View File

@@ -38,6 +38,8 @@ utoipa = { version = "5.3", optional = true }
# Feature pour pmoserver (extension HTTP)
axum = { version = "0.8", optional = true }
tower = { version = "0.5", optional = true }
tower-http = { version = "0.6", features = ["fs"], optional = true }
# Feature pour pmoconfig (extension de configuration)
pmoconfig = { path = "../pmoconfig", optional = true }
@@ -49,5 +51,5 @@ tempfile = "3"
[features]
default = []
openapi = ["dep:utoipa"]
pmoserver = ["dep:axum"]
pmoserver = ["dep:axum", "dep:tower", "dep:tower-http"]
pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"]

View File

@@ -90,10 +90,11 @@ async fn get_file<C: CacheConfig + 'static>(
Option<ParamGenerator<C>>,
)>,
Path(pk): Path<String>,
request: axum::http::Request<Body>,
) -> Response {
// Utiliser le param par défaut
let param = C::default_param();
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator).await
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await
}
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param}
@@ -106,8 +107,9 @@ async fn get_file_with_param<C: CacheConfig + 'static>(
Option<ParamGenerator<C>>,
)>,
Path((pk, param)): Path<(String, String)>,
request: axum::http::Request<Body>,
) -> Response {
serve_file_with_streaming(&cache, &pk, &param, content_type, param_generator).await
serve_file_with_streaming(&cache, &pk, &param, content_type, param_generator, request).await
}
#[cfg(feature = "pmoserver")]
@@ -116,6 +118,7 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
pk: &str,
param: &str,
content_type: &'static str,
request: axum::http::Request<Body>,
) -> Response {
let qualifier = param.to_string();
let file_path = cache.get_file_path_with_qualifier(pk, param);
@@ -129,7 +132,8 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
// la lecture pendant le téléchargement tout en préservant la durée/position
if let Some(download) = cache.get_download(pk).await {
if !download.finished().await {
let response = stream_file_progressive(file_path, download, content_type).await;
let response =
stream_file_progressive(file_path, download, content_type, request).await;
if response.status().is_success() {
cache.notify_broadcast(pk, &qualifier).await;
@@ -140,9 +144,8 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
}
// ROUTE 2 : Fichier complètement téléchargé
// Utilise l'ancien système éprouvé qui garantit un passage correct
// de toutes les informations (Content-Length automatique, etc.)
let response = serve_complete_file(file_path, content_type).await;
// Utilise ServeFile avec support des Range requests pour le seek
let response = serve_complete_file(file_path, content_type, request).await;
if response.status().is_success() {
cache.notify_broadcast(pk, &qualifier).await;
@@ -166,6 +169,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
lazy_pk: &str,
param: &str,
content_type: &'static str,
request: axum::http::Request<Body>,
) -> Response {
tracing::info!("Lazy download triggered for pk: {}", lazy_pk);
@@ -176,7 +180,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
lazy_pk,
real_pk
);
return serve_finalized_pk(cache, &real_pk, param, content_type).await;
return serve_finalized_pk(cache, &real_pk, param, content_type, request).await;
}
// 2. Télécharger en résolvant l'URL via la DB ou un provider
@@ -196,7 +200,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
cache.broadcast_lazy_downloaded(lazy_pk, &real_pk).await;
// 5. Servir directement le fichier téléchargé
serve_finalized_pk(cache, &real_pk, param, content_type).await
serve_finalized_pk(cache, &real_pk, param, content_type, request).await
}
/// Fonction utilitaire pour servir un fichier avec streaming progressif
@@ -211,10 +215,11 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
param: &str,
content_type: &'static str,
param_generator: Option<ParamGenerator<C>>,
request: axum::http::Request<Body>,
) -> Response {
// LAZY PK SUPPORT: Détecter si c'est un lazy PK
if crate::cache::is_lazy_pk(pk) {
return serve_lazy_audio_file(cache, pk, param, content_type).await;
return serve_lazy_audio_file(cache, pk, param, content_type, request).await;
}
let file_path = cache.get_file_path_with_qualifier(pk, param);
@@ -246,8 +251,9 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
if let Some(download) = cache.get_download(pk).await {
// Le fichier est en cours de téléchargement
if !download.finished().await {
// Streaming progressif
let response = stream_file_progressive(file_path, download, content_type).await;
// Streaming progressif avec support Range
let response =
stream_file_progressive(file_path, download, content_type, request).await;
if response.status().is_success() {
cache.notify_broadcast(pk, &qualifier).await;
@@ -257,8 +263,8 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
}
}
// Fichier terminé ou pas de download en cours, servir normalement
let response = serve_complete_file(file_path, content_type).await;
// Fichier terminé ou pas de download en cours, servir normalement avec support Range
let response = serve_complete_file(file_path, content_type, request).await;
if response.status().is_success() {
cache.notify_broadcast(pk, &qualifier).await;
@@ -268,13 +274,17 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
}
/// Stream un fichier en cours de téléchargement de manière progressive
///
/// Supporte les Range requests pour permettre le seek même pendant le téléchargement
#[cfg(feature = "pmoserver")]
async fn stream_file_progressive(
file_path: std::path::PathBuf,
download: Arc<crate::download::Download>,
content_type: &'static str,
request: axum::http::Request<Body>,
) -> Response {
use axum::http::header;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
// Attendre qu'au moins 64 KB soient disponibles avant de commencer
const MIN_SIZE_TO_START: u64 = 64 * 1024;
@@ -291,8 +301,14 @@ async fn stream_file_progressive(
return (StatusCode::NOT_FOUND, "File not available").into_response();
}
// Récupérer la taille attendue du fichier si disponible
let expected_size = download.expected_size().await;
// Vérifier s'il y a un header Range
let range_header = request.headers().get(header::RANGE);
// Ouvrir le fichier en lecture
let file = match tokio::fs::File::open(&file_path).await {
let mut file = match tokio::fs::File::open(&file_path).await {
Ok(f) => f,
Err(e) => {
warn!("Error opening file {:?}: {}", file_path, e);
@@ -300,24 +316,65 @@ async fn stream_file_progressive(
}
};
// Créer un stream à partir du fichier
// Parser le Range header si présent
if let Some(range_value) = range_header {
if let Ok(range_str) = range_value.to_str() {
// Format: "bytes=start-end" ou "bytes=start-"
if let Some(range_spec) = range_str.strip_prefix("bytes=") {
if let Some((start_str, _)) = range_spec.split_once('-') {
if let Ok(start) = start_str.parse::<u64>() {
// Seek vers la position demandée
if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {
warn!("Error seeking to position {}: {}", start, e);
return (StatusCode::RANGE_NOT_SATISFIABLE, "Invalid range")
.into_response();
}
// Créer un stream à partir de la position
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
// Récupérer la taille attendue du fichier si disponible
let expected_size = download.expected_size().await;
// Calculer la range
let end = expected_size.map(|s| s - 1).unwrap_or(start + 1_000_000);
let content_length = expected_size.map(|s| s - start);
// Retourner une réponse 206 Partial Content
let mut response_builder = axum::http::Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCEPT_RANGES, "bytes");
if let Some(total_size) = expected_size {
response_builder = response_builder.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, total_size),
);
}
if let Some(length) = content_length {
response_builder =
response_builder.header(header::CONTENT_LENGTH, length);
}
return response_builder.body(body).unwrap();
}
}
}
}
}
// Pas de Range header : retourner le fichier complet
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
// Construire la réponse avec Content-Length si connu
let mut response = axum::http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type);
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCEPT_RANGES, "bytes");
if let Some(size) = expected_size {
// Si on connaît la taille finale, l'envoyer au renderer
// pour qu'il puisse calculer la durée et afficher la position
response = response.header(header::CONTENT_LENGTH, size);
} else {
// Sinon, utiliser chunked encoding
response = response.header(header::TRANSFER_ENCODING, "chunked");
}
@@ -325,21 +382,45 @@ async fn stream_file_progressive(
}
/// Sert un fichier complet déjà téléchargé
///
/// Utilise `tower_http::services::ServeFile` pour bénéficier automatiquement de :
/// - Support des HTTP Range requests (seek)
/// - Header `Accept-Ranges: bytes`
/// - Réponses `206 Partial Content`
/// - Streaming efficace sans chargement complet en RAM
#[cfg(feature = "pmoserver")]
async fn serve_complete_file(
file_path: std::path::PathBuf,
content_type: &'static str,
request: axum::http::Request<Body>,
) -> Response {
use axum::http::header;
use tower::ServiceExt; // Pour oneshot()
use tower_http::services::ServeFile;
if !file_path.exists() {
warn!("File not found: {:?}", file_path);
return (StatusCode::NOT_FOUND, "File not found").into_response();
}
match tokio::fs::read(&file_path).await {
Ok(data) => (StatusCode::OK, [("content-type", content_type)], data).into_response(),
// Utiliser ServeFile avec support automatique des Range requests
// ServeFile traite automatiquement les headers Range: dans la requête
let serve_file = ServeFile::new(file_path);
match serve_file.oneshot(request).await {
Ok(response) => {
// Convertir la réponse ServeFile en Response<Body> avec le bon Content-Type
let (mut parts, body) = response.into_parts();
parts
.headers
.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
// Convertir ServeFileSystemResponseBody en Body
axum::http::Response::from_parts(parts, Body::new(body))
}
Err(e) => {
warn!("Error reading file {:?}: {}", file_path, e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response()
warn!("Error serving file: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error serving file").into_response()
}
}
}

View File

@@ -352,6 +352,16 @@ impl MusicRenderer {
.seek_rel_time(hhmmss)
}
/// Seek to a specific position in seconds
pub fn seek(&self, seconds: u32) -> Result<(), ControlPointError> {
// Convert seconds to HH:MM:SS format
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
let hhmmss = format!("{:02}:{:02}:{:02}", hours, minutes, secs);
self.seek_rel_time(&hhmmss)
}
/// Volume control: get current volume
pub fn volume(&self) -> Result<u16, ControlPointError> {
self.backend

View File

@@ -266,6 +266,14 @@ pub struct SeekQueueRequest {
pub index: usize,
}
/// Requête pour seek à une position spécifique (en secondes)
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SeekRequest {
/// Position en secondes
pub seconds: u32,
}
/// Requête pour transférer une queue d'un renderer vers un autre
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Deserialize, ToSchema)]
@@ -412,6 +420,7 @@ GET /control/servers/{server_id}/containers/{container_id}
crate::pmoserver_ext::stop_renderer,
crate::pmoserver_ext::resume_renderer,
crate::pmoserver_ext::next_renderer,
crate::pmoserver_ext::seek_renderer,
crate::pmoserver_ext::seek_queue_index,
crate::pmoserver_ext::set_renderer_volume,
crate::pmoserver_ext::volume_up_renderer,
@@ -445,6 +454,7 @@ GET /control/servers/{server_id}/containers/{container_id}
AttachPlaylistRequest,
PlayContentRequest,
SeekQueueRequest,
SeekRequest,
TransferQueueRequest,
SuccessResponse,
ErrorResponse,

View File

@@ -14,7 +14,7 @@ use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SeekQueueRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
SeekQueueRequest, SeekRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::queue::PlaybackItem;
@@ -718,6 +718,88 @@ async fn seek_queue_index(
}))
}
/// POST /control/renderers/{renderer_id}/seek - Seek à une position spécifique (en secondes)
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/control/renderers/{renderer_id}/seek",
tag = "control",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
request_body = SeekRequest,
responses(
(status = 200, description = "Seek effectué avec succès", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
(status = 504, description = "Timeout lors du seek", body = ErrorResponse),
(status = 500, description = "Erreur interne", body = ErrorResponse),
)
)]
async fn seek_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
Json(payload): Json<SeekRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Renderer {} not found", renderer_id),
}),
)
})?;
let seconds = payload.seconds;
let seek_task = tokio::task::spawn_blocking(move || renderer.seek(seconds));
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
.await
.map_err(|_| {
warn!(
"Seek command for renderer {} exceeded {:?}",
renderer_id, TRANSPORT_COMMAND_TIMEOUT
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Seek command timed out after {}s",
TRANSPORT_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!("Task join error during seek: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
"Failed to seek to {} seconds for renderer {}: {}",
seconds, renderer_id, e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to seek to {} seconds: {}", seconds, e),
}),
)
})?;
Ok(Json(SuccessResponse {
message: format!("Seeked to {} seconds", seconds),
}))
}
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
#[cfg(feature = "pmoserver")]
#[utoipa::path(
@@ -1882,6 +1964,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
.route("/renderers/{renderer_id}/stop", post(stop_renderer))
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
.route("/renderers/{renderer_id}/next", post(next_renderer))
.route("/renderers/{renderer_id}/seek", post(seek_renderer))
// Queue control
.route(
"/renderers/{renderer_id}/queue/seek",