feat: add web share target support and implement catalog search API #109

Merged
eric merged 1 commits from push-vsvqwqmmouro into main 2026-06-28 23:50:34 +02:00
10 changed files with 225 additions and 11 deletions
Showing only changes of commit 472211012b - Show all commits

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.59"
version = "0.3.61"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.60"
version = "0.3.61"
edition = "2024"
[dependencies]

View File

@@ -15,14 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// #[cfg(tokio_unstable)]
// console_subscriber::init();
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
server
.write()
.await
.add_route("/info", || async {
serde_json::json!({"version": "1.0.0"})
})
.await;
let server = Server::create_upnp_server().await?;
// Initialiser le système de gestion des sources musicales avec API REST
info!("📡 Initializing music sources management system...");
@@ -91,6 +84,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser les ProtocolInfo du MediaServer
server_instance.init_protocol_info();
let local_server_id = server_instance.udn().to_string();
// Exposer les informations de base de l'instance locale
{
let local_server_id_clone = local_server_id.clone();
server
.write()
.await
.add_route("/info", move || {
let id = local_server_id_clone.clone();
async move { serde_json::json!({"version": "1.0.0", "local_server_id": id}) }
})
.await;
}
info!(
"✅ MediaServer ready at {}{}",
server_instance.base_url(),

View File

@@ -10,7 +10,17 @@
</template>
<script setup lang="ts">
import { watch } from 'vue'
import NotificationToast from '@/components/NotificationToast.vue'
import { useShareTarget } from '@/composables/useShareTarget'
import { useUIStore } from '@/stores/ui'
const ui = useUIStore()
const { shareError } = useShareTarget()
watch(shareError, (err) => {
if (err) ui.notifyError(err)
})
</script>
<style scoped>

View File

@@ -579,6 +579,8 @@ export function useRenderers() {
volumeUp,
volumeDown,
toggleMute,
// Selection
selectedRendererId,
// Playlist binding
attachPlaylist,
detachPlaylist,

View File

@@ -0,0 +1,90 @@
import { ref, onMounted } from 'vue'
import { searchSource } from '@/services/pmosource'
import { useRenderers } from '@/composables/useRenderers'
export interface ShareTargetResult {
url: string
title: string | null
containerId: string
}
const pendingShare = ref<ShareTargetResult | null>(null)
const shareError = ref<string | null>(null)
let localServerId: string | null = null
async function fetchLocalServerId(): Promise<string | null> {
if (localServerId) return localServerId
try {
const resp = await fetch('/api/info')
if (!resp.ok) return null
const data = await resp.json()
localServerId = data.local_server_id ?? null
return localServerId
} catch {
return null
}
}
export function useShareTarget() {
const { selectedRendererId, attachAndPlayPlaylist } = useRenderers()
async function handleShareIfPresent() {
const params = new URLSearchParams(window.location.search)
const sharedUrl = params.get('share_url') ?? params.get('share_text') ?? null
const sharedTitle = params.get('share_title')
if (!sharedUrl) return
const clean = new URL(window.location.href)
clean.searchParams.delete('share_url')
clean.searchParams.delete('share_title')
clean.searchParams.delete('share_text')
window.history.replaceState({}, '', clean.toString())
try {
shareError.value = null
const result = await searchSource('url', sharedUrl)
if (result.total === 0) {
shareError.value = `Aucun contenu trouvé pour : ${sharedUrl}`
return
}
const container = result.containers[0] ?? null
const containerId = container?.id ?? result.items[0]?.id
if (!containerId) {
shareError.value = 'Contenu résolu mais sans identifiant jouable'
return
}
const serverId = await fetchLocalServerId()
const rendererId = selectedRendererId.value
if (!serverId || !rendererId) {
// Pas de renderer sélectionné ou serveur inconnu : stocker pour affichage manuel
pendingShare.value = { url: sharedUrl, title: sharedTitle, containerId }
return
}
await attachAndPlayPlaylist(rendererId, serverId, containerId)
} catch (e) {
shareError.value = e instanceof Error ? e.message : 'Erreur lors de la résolution'
}
}
function clearShare() {
pendingShare.value = null
shareError.value = null
}
onMounted(() => {
handleShareIfPresent()
})
return {
pendingShare,
shareError,
clearShare,
}
}

View File

@@ -171,6 +171,18 @@ export function getSourceImageUrl(sourceId: string): string {
return `${API_BASE}/${sourceId}/image`
}
/**
* Recherche dans une source musicale (URL, texte libre…)
*/
export async function searchSource(sourceId: string, query: string): Promise<BrowseResponse> {
const params = new URLSearchParams({ q: query })
const response = await fetch(`${API_BASE}/${sourceId}/search?${params.toString()}`)
if (!response.ok) {
throw new Error(`Search failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
/**
* Récupère les capacités d'une source
*/

View File

@@ -32,6 +32,15 @@ export default defineConfig({
purpose: 'any maskable',
},
],
share_target: {
action: '/app/',
method: 'GET',
params: {
url: 'share_url',
title: 'share_title',
text: 'share_text',
},
},
},
workbox: {
navigateFallback: '/app/index.html',

View File

@@ -1179,6 +1179,88 @@ async fn unregister_source_handler(Path(id): Path<String>) -> impl IntoResponse
}
}
/// Paramètres pour la recherche dans une source
#[cfg(feature = "server")]
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
struct SearchParams {
/// Texte de recherche (URL ou termes)
q: String,
}
/// Recherche dans une source musicale
#[cfg(feature = "server")]
#[utoipa::path(
get,
path = "/{id}/search",
params(
("id" = String, Path, description = "ID de la source"),
SearchParams
),
responses(
(status = 200, description = "Résultats de la recherche", body = SourceBrowseResponse),
(status = 404, description = "Source introuvable", body = ErrorResponse),
(status = 500, description = "Erreur lors de la recherche", body = ErrorResponse),
),
tag = "sources"
)]
async fn search_source(
Path(id): Path<String>,
Query(params): Query<SearchParams>,
) -> impl IntoResponse {
match get_source(&id).await {
Some(source) => {
let query = crate::SearchQuery {
text: params.q,
media_type: crate::MediaSearchType::All,
scope: crate::SearchScope::Catalog,
limit: 50,
offset: 0,
};
match source.search(&query).await {
Ok(result) => {
let (containers_raw, items_raw) = match result {
crate::BrowseResult::Containers(c) => (c, Vec::new()),
crate::BrowseResult::Items(i) => (Vec::new(), i),
crate::BrowseResult::Mixed { containers, items } => (containers, items),
};
let containers: Vec<BrowseContainerInfo> =
containers_raw.iter().map(BrowseContainerInfo::from).collect();
let items: Vec<BrowseItemInfo> =
items_raw.iter().map(BrowseItemInfo::from).collect();
let returned_containers = containers.len();
let returned_items = items.len();
let total = returned_containers + returned_items;
let update_id = source.update_id().await;
let response = SourceBrowseResponse {
object_id: source.id().to_string(),
containers,
items,
returned_containers,
returned_items,
total,
update_id,
};
(StatusCode::OK, Json(response)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Search failed: {}", e),
}),
)
.into_response(),
}
}
None => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Source '{}' not found", id),
}),
)
.into_response(),
}
}
/// Crée le router pour l'API des sources (endpoints de lecture uniquement)
///
/// # Returns
@@ -1216,6 +1298,7 @@ pub fn create_sources_router() -> Router {
.route("/{id}/cache/status", get(get_source_cache_status))
.route("/{id}/cache", post(request_source_cache))
.route("/{id}/formats", get(get_source_formats))
.route("/{id}/search", get(search_source))
}
/// Structure pour la documentation OpenAPI de base

View File

@@ -1 +1 @@
0.3.60
0.3.61