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]]

File diff suppressed because it is too large Load Diff

View File

@@ -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>

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);
// 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()
}
}
}

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",