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:
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +1,221 @@
|
||||
<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)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause() {
|
||||
try {
|
||||
await pause(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
try {
|
||||
await pause(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
try {
|
||||
await stop(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
try {
|
||||
await stop(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
try {
|
||||
await next(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
try {
|
||||
await next(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transport-controls">
|
||||
<button
|
||||
class="btn btn-icon btn-primary"
|
||||
:disabled="isPlaying"
|
||||
@click="handlePlay"
|
||||
title="Lecture"
|
||||
>
|
||||
<Play :size="20" />
|
||||
</button>
|
||||
<div class="transport-controls">
|
||||
<button
|
||||
class="btn btn-icon btn-primary"
|
||||
:disabled="isPlaying"
|
||||
@click="handlePlay"
|
||||
title="Lecture"
|
||||
>
|
||||
<Play :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isPaused || isStopped"
|
||||
@click="handlePause"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause :size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isPaused || isStopped"
|
||||
@click="handlePause"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isStopped"
|
||||
@click="handleStop"
|
||||
title="Stop"
|
||||
>
|
||||
<Square :size="20" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isStopped"
|
||||
@click="handleStop"
|
||||
title="Stop"
|
||||
>
|
||||
<Square :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="!state?.queue_len"
|
||||
@click="handleNext"
|
||||
title="Suivant"
|
||||
>
|
||||
<SkipForward :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="!state?.queue_len"
|
||||
@click="handleNext"
|
||||
title="Suivant"
|
||||
>
|
||||
<SkipForward :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* Boutons avec effet glass */
|
||||
.transport-controls .btn-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
min-height: 56px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateZ(0);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
min-height: 56px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:disabled {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Bouton primary (play) avec accent vert */
|
||||
.transport-controls .btn-primary {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.transport-controls .btn-primary:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.4);
|
||||
border-color: rgba(34, 197, 94, 0.6);
|
||||
background: rgba(34, 197, 94, 0.4);
|
||||
border-color: rgba(34, 197, 94, 0.6);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.transport-controls .btn-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
@supports not (backdrop-filter: blur(20px)) {
|
||||
.transport-controls {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
|
||||
@media (max-height: 700px) and (orientation: landscape) {
|
||||
.transport-controls {
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
.transport-controls {
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.transport-controls .btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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, ¶m, content_type, param_generator).await
|
||||
serve_file_with_streaming(&cache, &pk, ¶m, 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// Récupérer la taille attendue du fichier si disponible
|
||||
let expected_size = download.expected_size().await;
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user