Merge pull request 'push-xmrpvpxvppzw' (#43) from push-xmrpvpxvppzw into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 29m41s

Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
2026-01-10 16:45:55 +01:00
11 changed files with 1063 additions and 565 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

@@ -1,84 +1,86 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
color: #535bf2;
}
body {
margin: 0;
display: flex;
flex-direction: column;
min-width: 320px;
min-height: 100vh;
width: 100%;
overflow-x: hidden;
margin: 0;
display: flex;
flex-direction: column;
min-width: 320px;
min-height: 100vh;
width: 100%;
overflow-x: hidden;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
padding: 2em;
}
#app {
width: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
zoom: 0.85;
transform-origin: top left;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

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

@@ -1318,8 +1318,17 @@ impl ControlPoint {
);
}
// 5b. Detach playlist binding from source renderer
self.detach_queue_playlist(source_renderer_id);
tracing::debug!(
source = source_renderer_id.0.as_str(),
"Detached playlist binding from source renderer"
);
// 6. Start playback on destination renderer (if there was a current item)
if source_snapshot.current_index.is_some() && !source_snapshot.items.is_empty() {
// play() détecte automatiquement la queue et joue le track courant
// (comportement unifié pour tous les backends)
if let Err(e) = dest_renderer.play() {
tracing::warn!(
dest = dest_renderer_id.0.as_str(),

View File

@@ -298,8 +298,24 @@ impl MusicRenderer {
}
/// Transport control: play
///
/// Démarre ou reprend la lecture. Si une queue non vide existe,
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
pub fn play(&self) -> Result<(), ControlPointError> {
self.backend.lock().expect("Backend mutex poisoned").play()
// Vérifier si on a une queue non vide
let queue = self.queue.lock().expect("Queue mutex poisoned");
let queue_not_empty = queue.len().unwrap_or(0) > 0;
drop(queue); // Libérer le lock avant l'appel au backend
if queue_not_empty {
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
// peek_current() initialise automatiquement l'index à 0 si nécessaire
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
self.play_current_from_queue()
} else {
// Queue vide : déléguer au backend (reprend la lecture en cours, etc.)
self.backend.lock().expect("Backend mutex poisoned").play()
}
}
/// Transport control: pause
@@ -336,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",