From 5c8dcee9baeafa089f4e57ecb4cc9dcca9e8d5ab Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 11 Oct 2026 18:03:45 +0300 Subject: [PATCH] feat: validate audio URLs on target players and fix Nix packaging --- .../rockskyengine/RockskyEngineModule.kt | 8 + apps/app/modules/rocksky-engine/index.ts | 12 + apps/app/native/engine/Cargo.lock | 71 +++- apps/app/native/engine/src/lib.rs | 13 +- apps/app/native/metadata/Cargo.lock | 317 ++++++++++++++++- apps/app/native/metadata/Cargo.toml | 4 +- apps/app/native/metadata/src/audio_url.rs | 321 ++++++++++++++++++ apps/app/native/metadata/src/lib.rs | 2 + apps/app/src/atoms/devices.ts | 1 + apps/app/src/hooks/useRemoteDevices.tsx | 3 + apps/app/src/lib/libraryPlayback.ts | 76 ++++- apps/app/src/lib/remoteBridge.ts | 31 +- apps/app/src/lib/uploadEngine.ts | 69 +++- apps/app/src/screens/Library/OpenAudioUrl.tsx | 145 ++++++++ .../app/src/screens/Library/RemoteLibrary.tsx | 9 + apps/app/src/screens/Library/index.tsx | 2 + crates/rocksky-nif/src/lib.rs | 12 + crates/rocksky-sdk/examples/remote_control.rs | 5 + crates/rocksky-sdk/src/remote_controller.rs | 35 ++ crates/rocksky-sdk/src/remote_player.rs | 30 ++ crates/rocksky-uniffi/src/capi.rs | 12 + crates/rocksky-uniffi/src/lib.rs | 18 + desktop/src-tauri/Cargo.lock | 237 +++++++++++++ desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/player.rs | 35 ++ desktop/src-tauri/src/remote.rs | 33 +- desktop/src/atoms/queue.ts | 1 + desktop/src/components/OpenAudioUrl.tsx | 139 ++++++++ .../StickyPlayer/StickyPlayerWithData.tsx | 5 +- desktop/src/hooks/useRockboxEngine.ts | 3 +- desktop/src/hooks/useUploadPlayer.ts | 157 ++++++--- desktop/src/lib/tauri-rockbox/index.ts | 2 + desktop/src/pages/library/Library.tsx | 2 + flake.nix | 9 + playerd/Cargo.lock | 291 ++++++++++++++-- playerd/Cargo.toml | 1 + playerd/src/main.rs | 18 +- playerd/src/mcp/state.rs | 21 ++ playerd/src/mcp/subsonic.rs | 4 + playerd/src/mcp/tools/player.rs | 22 +- playerd/src/remote.rs | 13 + playerd/src/resolver.rs | 32 ++ playerd/src/resume.rs | 3 + remote-ws/PROTOCOL.md | 24 +- remote-ws/lib/remote_ws/ws/handler.ex | 3 + remote-ws/test/remote_ws/ws/handler_test.exs | 14 + rocksky-mcp/src/state.rs | 10 + sdk/typescript/src/remote-controller.ts | 9 +- sdk/typescript/src/remote-player.ts | 9 + 50 files changed, 2182 insertions(+), 113 deletions(-) create mode 100644 apps/app/native/metadata/src/audio_url.rs create mode 100644 apps/app/src/screens/Library/OpenAudioUrl.tsx create mode 100644 desktop/src/components/OpenAudioUrl.tsx diff --git a/apps/app/modules/rocksky-engine/android/src/main/java/expo/modules/rockskyengine/RockskyEngineModule.kt b/apps/app/modules/rocksky-engine/android/src/main/java/expo/modules/rockskyengine/RockskyEngineModule.kt index ba7c61e9..ebdfa876 100644 --- a/apps/app/modules/rocksky-engine/android/src/main/java/expo/modules/rockskyengine/RockskyEngineModule.kt +++ b/apps/app/modules/rocksky-engine/android/src/main/java/expo/modules/rockskyengine/RockskyEngineModule.kt @@ -150,6 +150,14 @@ class RockskyEngineModule : Module() { else MediaRendererService.configure(context, input) } + AsyncFunction("prepareAudioUrl") { url: String -> + check(ensureLoaded()) { "Native audio engine is unavailable" } + val context = requireNotNull(appContext.reactContext).applicationContext + val cache = java.io.File(context.cacheDir, "audio-urls").apply { mkdirs() } + NativeEngine.command(org.json.JSONObject().put("cmd", "prepareAudioUrl") + .put("url", url).put("cacheDir", cache.absolutePath).toString()) + } + AsyncFunction("rendererArtwork") { url: String -> check(ensureLoaded()) { "Native audio engine is unavailable" } rendererArtwork(url) diff --git a/apps/app/modules/rocksky-engine/index.ts b/apps/app/modules/rocksky-engine/index.ts index 9d5ae46f..b3d6a662 100644 --- a/apps/app/modules/rocksky-engine/index.ts +++ b/apps/app/modules/rocksky-engine/index.ts @@ -2,6 +2,7 @@ import type { RemoteAudioSettings } from "@rocksky/sdk"; import { requireOptionalNativeModule } from "expo"; type RockskyEngineNativeModule = { + prepareAudioUrl(url: string): Promise; sharePost(json: string): Promise; mediaRenderer(json: string): Promise; rendererArtwork(url: string): Promise; @@ -180,3 +181,14 @@ export async function sharePost(input: { if (!native?.sharePost) throw new Error("Sharing posts requires an updated Android build."); return native.sharePost(JSON.stringify(input)); } + +export type AudioUrlMetadata = { + url: string; cacheId: string; title: string; artist?: string; album?: string; + albumArtist?: string; albumArt?: string | null; durationMs?: number; mimeType?: string; +}; +export async function prepareAudioUrl(url: string): Promise { + if (!native?.prepareAudioUrl) throw new Error("Audio URLs require an updated Android build."); + const result = JSON.parse(await native.prepareAudioUrl(url)); + if (!result.ok) throw new Error(result.error || "Could not validate audio URL"); + return result.metadata; +} diff --git a/apps/app/native/engine/Cargo.lock b/apps/app/native/engine/Cargo.lock index 25170062..b2acae22 100644 --- a/apps/app/native/engine/Cargo.lock +++ b/apps/app/native/engine/Cargo.lock @@ -111,6 +111,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -181,7 +190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core", ] @@ -271,6 +280,15 @@ dependencies = [ "windows", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -289,6 +307,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -301,6 +329,16 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -458,6 +496,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1468,10 +1516,14 @@ dependencies = [ name = "rocksky-metadata" version = "0.1.0" dependencies = [ + "base64 0.22.1", "lofty", "reqwest", "rockbox-metadata", "serde_json", + "sha2", + "symphonia", + "tempfile", ] [[package]] @@ -1721,6 +1773,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2218,6 +2281,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/apps/app/native/engine/src/lib.rs b/apps/app/native/engine/src/lib.rs index 12a420ca..60f0f462 100644 --- a/apps/app/native/engine/src/lib.rs +++ b/apps/app/native/engine/src/lib.rs @@ -16,6 +16,10 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize)] #[serde(tag = "cmd", rename_all = "camelCase", rename_all_fields = "camelCase")] enum Request { + PrepareAudioUrl { + url: String, + cache_dir: String, + }, RendererConfigure { config: Option, }, @@ -405,6 +409,12 @@ fn handle_internal(input: &str, from_renderer: bool) -> String { renderer.release(); } } + if let Request::PrepareAudioUrl { url, cache_dir } = &request { + return match rocksky_metadata::audio_url::prepare(url, std::path::Path::new(cache_dir)) { + Ok(metadata) => serde_json::json!({"ok":true,"metadata":metadata}).to_string(), + Err(error) => err(error), + }; + } if let Request::ReadRemoteMetadata { url, cache_path, @@ -458,7 +468,8 @@ fn handle_internal(input: &str, from_renderer: bool) -> String { Err(e) => return err(e), }; match request { - Request::RendererConfigure { .. } + Request::PrepareAudioUrl { .. } + | Request::RendererConfigure { .. } | Request::RendererRelease | Request::RendererStatus | Request::ReadRemoteMetadata { .. } diff --git a/apps/app/native/metadata/Cargo.lock b/apps/app/native/metadata/Cargo.lock index f2a1fb57..e1f7111e 100644 --- a/apps/app/native/metadata/Cargo.lock +++ b/apps/app/native/metadata/Cargo.lock @@ -8,6 +8,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -26,18 +32,39 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" @@ -79,10 +106,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -101,12 +143,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "data-encoding" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -118,6 +180,20 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "errno" version = "0.3.14" @@ -128,6 +204,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.5.0" @@ -209,6 +291,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -461,6 +553,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" @@ -544,6 +642,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "ogg_pager" version = "0.7.0" @@ -759,10 +863,13 @@ dependencies = [ name = "rocksky-metadata" version = "0.1.0" dependencies = [ + "base64 0.22.1", "lofty", "reqwest", "rockbox-metadata", "serde_json", + "sha2", + "symphonia", "tempfile", ] @@ -778,7 +885,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -832,6 +939,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -886,6 +999,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -898,6 +1022,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -932,6 +1062,175 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "2.0.119" @@ -1068,7 +1367,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -1117,6 +1416,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -1147,6 +1452,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.2" diff --git a/apps/app/native/metadata/Cargo.toml b/apps/app/native/metadata/Cargo.toml index 2b8cf275..ee5ee616 100644 --- a/apps/app/native/metadata/Cargo.toml +++ b/apps/app/native/metadata/Cargo.toml @@ -10,7 +10,9 @@ lofty = "0.22" serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls-webpki-roots"] } -[dev-dependencies] tempfile = "3" +sha2 = "0.10" +base64 = "0.22" +symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "aiff", "pcm", "adpcm"] } [workspace] diff --git a/apps/app/native/metadata/src/audio_url.rs b/apps/app/native/metadata/src/audio_url.rs new file mode 100644 index 00000000..449e2b06 --- /dev/null +++ b/apps/app/native/metadata/src/audio_url.rs @@ -0,0 +1,321 @@ +//! Validate external audio before queue mutation; cache tags/art, never URL credentials. +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Read, Seek, SeekFrom, Write}, + path::Path, + time::Duration, +}; +use symphonia::core::{ + codecs::DecoderOptions, formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, + probe::Hint, +}; + +/// Blocking: callers must use their native worker or spawn_blocking, never the UI thread. +pub fn prepare(input: &str, directory: &Path) -> Result { + let mut url = + reqwest::Url::parse(input.trim()).map_err(|_| "Enter a valid HTTP or HTTPS audio URL")?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err("Only HTTP and HTTPS audio URLs are supported".into()); + } + url.set_fragment(None); + fs::create_dir_all(directory).map_err(|_| "Could not create audio metadata cache")?; + let client = reqwest::blocking::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(20)) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .map_err(|_| "Audio network unavailable")?; + let mut response = client.get(url.clone()).header("Range", "bytes=0-8388607") + .header("Icy-MetaData", "0").send().map_err(|error| { + if error.is_timeout() { + "This player could not reach the audio host/IP before the connection timed out".to_string() + } else if error.is_connect() { + "The audio host/IP is unreachable from this player, refused the connection, or could not be resolved".to_string() + } else { + "This player could not fetch the audio URL (network, TLS, or redirect error)".to_string() + } + })?; + if !response.status().is_success() { + return Err(format!( + "Audio URL returned HTTP {}", + response.status().as_u16() + )); + } + let total = if response.status().as_u16() == 206 { + let range = response + .headers() + .get("content-range") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !range.starts_with("bytes 0-") { + return Err("Server returned an invalid audio byte range".into()); + } + range + .split_once('/') + .and_then(|(_, n)| n.parse::().ok()) + .filter(|n| *n <= 8 * 1024 * 1024 * 1024) + } else { + None + }; + let mime = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .to_string(); + // Unknown-length/live streams need only a probe, not a full download. + let limit = response + .content_length() + .map(|n| n.min(8 * 1024 * 1024)) + .unwrap_or(64 * 1024); + let mut bytes = Vec::new(); + let read_result = (&mut response).take(limit).read_to_end(&mut bytes); + if bytes.is_empty() { + return Err("Audio URL returned no readable audio".into()); + } + if read_result.is_err() && response.content_length().is_some() { + return Err("Audio download was interrupted".into()); + } + let mut fingerprint = Sha256::new(); + fingerprint.update(&bytes); + fingerprint.update(total.unwrap_or(0).to_le_bytes()); + let mut buffer = + tempfile::NamedTempFile::new_in(directory).map_err(|_| "Could not create audio probe")?; + buffer + .write_all(&bytes) + .map_err(|_| "Could not write audio probe")?; + if let Some(total) = total.filter(|n| *n > bytes.len() as u64) { + buffer + .as_file() + .set_len(total) + .map_err(|_| "Could not size audio probe")?; + let start = total + .saturating_sub(2 * 1024 * 1024) + .max(bytes.len() as u64); + if let Ok(mut tail) = client + .get(url.clone()) + .header("Range", format!("bytes={start}-{}", total - 1)) + .send() + { + let expected = format!("bytes {start}-"); + if tail.status().as_u16() == 206 + && tail + .headers() + .get("content-range") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with(&expected)) + { + buffer + .seek(SeekFrom::Start(start)) + .map_err(|_| "Could not seek audio probe")?; + let mut tail_bytes = Vec::new(); + (&mut tail) + .take(2 * 1024 * 1024) + .read_to_end(&mut tail_bytes) + .map_err(|_| "Could not read trailing audio metadata")?; + fingerprint.update(&tail_bytes); + buffer + .write_all(&tail_bytes) + .map_err(|_| "Could not write audio probe")?; + } + } + } + buffer.flush().map_err(|_| "Could not flush audio probe")?; + // Neither the extension nor Content-Type proves audio. Decode a real packet. + let file = fs::File::open(buffer.path()).map_err(|_| "Could not open audio probe")?; + let source = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(extension) = Path::new(url.path()).extension().and_then(|e| e.to_str()) { + hint.with_extension(extension); + } + let mut probed = symphonia::default::get_probe() + .format( + &hint, + source, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|_| "URL does not contain supported audio")?; + let track = probed + .format + .tracks() + .iter() + .find(|t| { + symphonia::default::get_codecs() + .make(&t.codec_params, &DecoderOptions::default()) + .is_ok() + }) + .ok_or("Audio codec is not supported")?; + let track_id = track.id; + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|_| "Audio codec is not supported")?; + let mut decoded = false; + for _ in 0..128 { + let Ok(packet) = probed.format.next_packet() else { + break; + }; + if packet.track_id() == track_id + && decoder + .decode(&packet) + .is_ok_and(|audio| audio.frames() > 0) + { + decoded = true; + break; + } + } + if !decoded { + return Err("URL contains no decodable audio, or its codec is unsupported".into()); + } + let key = format!("{:x}", Sha256::digest(url.as_str().as_bytes())); + let fingerprint = format!("{:x}", fingerprint.finalize()); + let cache_path = directory.join(format!("{key}.json")); + // Reuse tags/art only after this target has fetched and decoded current audio. + if let Ok(file) = fs::File::open(&cache_path) { + if let Ok(mut cached) = serde_json::from_reader::<_, Value>(file) { + if cached["probeFingerprint"].as_str() == Some(&fingerprint) { + cached["url"] = json!(url.as_str()); + return Ok(cached); + } + } + } + // A unique temporary cover prevents stale or concurrent artwork from leaking into tags. + let art = tempfile::NamedTempFile::new_in(directory).map_err(|_| "Could not cache artwork")?; + let mut metadata = + crate::metadata::read(buffer.path(), art.path()).unwrap_or_else(|_| json!({})); + if metadata["title"].as_str().unwrap_or("").is_empty() { + metadata["title"] = json!(url + .path_segments() + .and_then(|mut p| p.next_back()) + .filter(|s| !s.is_empty()) + .unwrap_or("Audio stream")); + } + metadata["cacheId"] = json!(key); + metadata["mimeType"] = json!(mime); + // A portable data URL works in native views, webviews and remote controllers. + metadata["albumArt"] = Value::Null; + if let Ok(cover) = fs::read(art.path()) { + if !cover.is_empty() && cover.len() <= 512 * 1024 { + use base64::Engine; + let mime = if cover.starts_with(b"\x89PNG") { + "image/png" + } else if cover.starts_with(b"RIFF") { + "image/webp" + } else if cover.starts_with(b"GIF8") { + "image/gif" + } else { + "image/jpeg" + }; + metadata["albumArt"] = json!(format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(cover) + )); + } + } + metadata["probeFingerprint"] = json!(fingerprint); + let mut cached = + tempfile::NamedTempFile::new_in(directory).map_err(|_| "Could not cache audio metadata")?; + serde_json::to_writer(&mut cached, &metadata).map_err(|_| "Could not cache audio metadata")?; + cached + .persist(directory.join(format!("{key}.json"))) + .map_err(|_| "Could not save audio metadata")?; + metadata["url"] = json!(url.as_str()); + Ok(metadata) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + fn serve(status: &str, mime: &str, body: Vec) -> (String, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!( + "http://{}/audio?secret=keep-private", + listener.local_addr().unwrap() + ); + let status = status.to_owned(); + let mime = mime.to_owned(); + let handle = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 4096]; + let _ = socket.read(&mut request); + write!(socket, "HTTP/1.1 {status}\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).unwrap(); + let _ = socket.write_all(&body); + }); + (url, handle) + } + #[test] + fn rejects_http_errors_and_html_even_when_named_audio() { + for (status, mime, body) in [ + ("404 Not Found", "audio/mpeg", b"missing".to_vec()), + ("200 OK", "audio/mpeg", b"not audio".to_vec()), + ("200 OK", "audio/wav", vec![]), + ] { + let (url, server) = serve(status, mime, body); + let dir = tempfile::tempdir().unwrap(); + assert!(prepare(&url, dir.path()).is_err()); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); + server.join().unwrap(); + } + } + #[test] + fn extracts_and_caches_real_audio_without_persisting_url_credentials() { + let bytes = fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../../crates/audio/tests/fixtures/tagged.mp3"), + ) + .unwrap(); + let (url, server) = serve("200 OK", "application/octet-stream", bytes); + let dir = tempfile::tempdir().unwrap(); + let metadata = prepare(&url, dir.path()).unwrap(); + assert!(!metadata["title"].as_str().unwrap().is_empty()); + assert!(!metadata["artist"].as_str().unwrap().is_empty()); + assert!(metadata["albumArt"] + .as_str() + .unwrap() + .starts_with("data:image/")); + assert_eq!(metadata["url"], url); + let cached = fs::read_to_string( + dir.path() + .join(format!("{}.json", metadata["cacheId"].as_str().unwrap())), + ) + .unwrap(); + assert!(!cached.contains("keep-private")); + assert!(serde_json::from_str::(&cached) + .unwrap() + .get("url") + .is_none()); + server.join().unwrap(); + } + #[test] + fn rejects_unreachable_host_without_caching() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/audio", listener.local_addr().unwrap()); + drop(listener); // A known closed port: no external network dependency. + let dir = tempfile::tempdir().unwrap(); + let error = prepare(&url, dir.path()).unwrap_err(); + assert!( + error.contains("unreachable") || error.contains("timed out"), + "{error}" + ); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[test] + fn rejects_non_http_urls() { + let dir = tempfile::tempdir().unwrap(); + for url in [ + "invalid", + "file:///etc/passwd", + "ftp://localhost/music.mp3", + "javascript:alert(1)", + ] { + assert!(prepare(url, dir.path()).is_err()); + } + } +} diff --git a/apps/app/native/metadata/src/lib.rs b/apps/app/native/metadata/src/lib.rs index 3ae311b4..b08692c8 100644 --- a/apps/app/native/metadata/src/lib.rs +++ b/apps/app/native/metadata/src/lib.rs @@ -1,3 +1,5 @@ //! Shared local and bounded remote tag/artwork readers. pub mod metadata; pub mod remote_metadata; + +pub mod audio_url; diff --git a/apps/app/src/atoms/devices.ts b/apps/app/src/atoms/devices.ts index 6a1b25ee..1e659cc3 100644 --- a/apps/app/src/atoms/devices.ts +++ b/apps/app/src/atoms/devices.ts @@ -23,6 +23,7 @@ export type DeviceTrack = { }; export type DeviceQueueTrack = { + url?: string; trackId?: string; uploadId?: string; title: string; diff --git a/apps/app/src/hooks/useRemoteDevices.tsx b/apps/app/src/hooks/useRemoteDevices.tsx index e3fa2503..b8743f98 100644 --- a/apps/app/src/hooks/useRemoteDevices.tsx +++ b/apps/app/src/hooks/useRemoteDevices.tsx @@ -1,3 +1,4 @@ +import { Alert } from "react-native"; import { RemoteController, type RemoteNowPlaying, @@ -47,6 +48,7 @@ function toDeviceTrack(track: RemoteNowPlaying): DeviceTrack | null { function toQueue(queue: RemoteQueueItem[] | undefined): DeviceQueueTrack[] { if (!Array.isArray(queue)) return []; return queue.map((item) => ({ + url: item.url, trackId: item.trackId, uploadId: item.uploadId, title: item.title, @@ -157,6 +159,7 @@ export function useRemoteDevicesConnection() { }; }); }) + .on("commandError", ({ deviceName, message }) => Alert.alert(`${deviceName || "Player"}: playback rejected`, message)) .on("queue", ({ deviceId, deviceName, index, queue }) => { if (!deviceId) return; setDevices((prev) => { diff --git a/apps/app/src/lib/libraryPlayback.ts b/apps/app/src/lib/libraryPlayback.ts index 545a3c87..360b53c8 100644 --- a/apps/app/src/lib/libraryPlayback.ts +++ b/apps/app/src/lib/libraryPlayback.ts @@ -1,3 +1,5 @@ +import { remoteLibraries } from "../api/remoteLibraries"; +import { remoteBridge } from "./remoteBridge"; import { Alert } from "react-native"; import type { UploadedTrack } from "../api/uploads"; import { castPlayback } from "./castPlayback"; @@ -26,7 +28,48 @@ export function uploadToQueueTrack(item: UploadedTrack): UploadQueueTrack { }; } -export async function playQueue(tracks: UploadQueueTrack[], index: number) { +/** Resolve the library reference here; only the destination fetches and validates audio. */ +export async function sendToSelectedPlayer( + tracks: UploadQueueTrack[], + mode: "now" | "next" | "last", + startIndex = 0, +): Promise { + const target = remoteBridge.selectedTarget(); + if (!target) return false; + const items = await Promise.all( + tracks.map(async (track) => { + if (track.localId) + throw new Error( + "Local files cannot be reached by the selected remote player.", + ); + const url = + track.audioUrl || + (track.remoteLibraryId && track.remoteTrackId + ? await remoteLibraries.stream( + track.remoteLibraryId, + track.remoteTrackId, + ) + : track.streamUrl); + return { + url, + uploadId: url || track.navidromeId ? undefined : track.uploadId, + trackId: url ? undefined : track.navidromeId, + title: track.title, + artist: track.artist, + album: track.album, + albumArtist: track.albumArtist, + durationMs: track.durationMs, + songUri: track.songUri ?? undefined, + albumUri: track.albumUri ?? undefined, + }; + }), + ); + remoteBridge.enqueue(target, items, mode, startIndex); + return true; +} + +async function playQueueInternal(tracks: UploadQueueTrack[], index: number) { + if (await sendToSelectedPlayer(tracks, "now", index)) return; if (!castPlayback.connected() && !isLocalEngineAvailable()) { Alert.alert( "Playback unavailable", @@ -44,10 +87,11 @@ export async function playQueue(tracks: UploadQueueTrack[], index: number) { export const playUploadedTracks = (tracks: UploadedTrack[], index: number) => playQueue(tracks.map(uploadToQueueTrack), index); -export async function queueTracks( +async function queueTracksInternal( tracks: UploadQueueTrack[], where: "next" | "last", ) { + if (await sendToSelectedPlayer(tracks, where)) return; if (!castPlayback.connected() && !isLocalEngineAvailable()) { Alert.alert( "Playback unavailable", @@ -63,3 +107,31 @@ export async function queueTracks( Alert.alert("Queue failed", "Could not add that to the playback queue."); } } + +export async function playQueue(tracks: UploadQueueTrack[], index: number) { + try { + await playQueueInternal(tracks, index); + } catch (error) { + Alert.alert( + "Playback rejected", + error instanceof Error + ? error.message + : "The selected player could not play this track.", + ); + } +} +export async function queueTracks( + tracks: UploadQueueTrack[], + where: "next" | "last", +) { + try { + await queueTracksInternal(tracks, where); + } catch (error) { + Alert.alert( + "Queue rejected", + error instanceof Error + ? error.message + : "The selected player could not queue this track.", + ); + } +} diff --git a/apps/app/src/lib/remoteBridge.ts b/apps/app/src/lib/remoteBridge.ts index 8e6d68eb..86de6d62 100644 --- a/apps/app/src/lib/remoteBridge.ts +++ b/apps/app/src/lib/remoteBridge.ts @@ -1,7 +1,11 @@ -import type { RemoteController } from "@rocksky/sdk/remote"; +import type { RemoteQueueItem, RemoteController } from "@rocksky/sdk/remote"; import axios from "axios"; import { getDefaultStore } from "jotai"; -import { activeDeviceIdAtom, selectedSourceAtom } from "../atoms/devices"; +import { + activeDeviceIdAtom, + devicesAtom, + selectedSourceAtom, +} from "../atoms/devices"; import { nowPlayingAtom, playbackLockedUntilAtom, @@ -66,6 +70,29 @@ export const remoteBridge = { state.target = target; }, + selectedTarget(): string | null { + const selected = store.get(selectedSourceAtom); + if (selected?.kind === "spotify") throw new Error("Spotify cannot play external audio URLs. Select a Rocksky player."); + return selected?.kind === "device" + ? selected.id + : selected + ? null + : store.get(playerAtom) === "rockbox" + ? store.get(activeDeviceIdAtom) + : null; + }, + + enqueue( + target: string, + tracks: RemoteQueueItem[], + mode: "now" | "next" | "last", + startIndex = 0, + ) { + if (!target.trim() || !state.controller || !store.get(devicesAtom)[target]) + throw new Error("The selected player is disconnected."); + state.controller.enqueue(target, tracks, mode, false, startIndex); + }, + // The in-app native engine (uploaded-track playback) registers here; while // it is the active source every transport action goes straight to it. setLocalHandler(handler: LocalTransportHandler | null) { diff --git a/apps/app/src/lib/uploadEngine.ts b/apps/app/src/lib/uploadEngine.ts index c5508d38..72b8610b 100644 --- a/apps/app/src/lib/uploadEngine.ts +++ b/apps/app/src/lib/uploadEngine.ts @@ -16,6 +16,7 @@ import { mediaRenderer, type RendererTrack, localMusicNative, + prepareAudioUrl, engineCommand as nativeEngineCommand, } from "../../modules/rocksky-engine"; import { getSongLikeState, like, unlike } from "../api/likes"; @@ -62,6 +63,7 @@ import { remoteBridge, type TransportAction } from "./remoteBridge"; // now-playing atoms, routes transport actions, and scrobbles. export type UploadQueueTrack = { + audioUrl?: string; renderer?: boolean; remoteLibraryId?: string; remoteTrackId?: string; @@ -747,7 +749,7 @@ function maybeScrobble(track: UploadQueueTrack, status: EngineStatus) { albumArt: !track.remoteLibraryId && !track.renderer && - track.albumArt?.startsWith("https://") + (track.albumArt?.startsWith("https://") || track.albumArt?.startsWith("data:image/")) ? track.albumArt : undefined, duration, @@ -1047,6 +1049,7 @@ async function playbackPaths( tracks: UploadQueueTrack[], ): Promise { if (!castPlayback.connected()) return resolvePaths(tracks); + for (const track of tracks) if (track.audioUrl || track.streamUrl?.match(/^https?:/i)) await validateTrackUrl(track, track.audioUrl || track.streamUrl!); const owner = storage.getToken(); let localTracks: Promise> | undefined; const resolve: CastPathResolver = async (track, _index, signal) => { @@ -1079,6 +1082,9 @@ async function playbackPaths( } async function resolvePaths(tracks: UploadQueueTrack[]): Promise { + for (const track of tracks) { + if (track.audioUrl || track.streamUrl?.match(/^https?:/i)) await validateTrackUrl(track, track.audioUrl || track.streamUrl!); + } if (tracks.some((track) => track.localId)) { const library = await localMusicNative.library(); const deviceTracks = new Map( @@ -1120,7 +1126,11 @@ async function resolvePaths(tracks: UploadQueueTrack[]): Promise { if (needsToken && !casting) await ensureStreamToken(); return mapConcurrent(tracks, async (track) => track.remoteLibraryId && track.remoteTrackId - ? await remoteLibraries.stream(track.remoteLibraryId, track.remoteTrackId) + ? await (async () => { + const url = await remoteLibraries.stream(track.remoteLibraryId!, track.remoteTrackId!); + await validateTrackUrl(track, url); + return track.streamUrl!; + })() : track.localId ? await localMusicNative.path(track.localId) : casting && !track.navidromeId && !track.streamUrl @@ -1137,7 +1147,7 @@ export async function playUploads( const session = storage.getToken(); if ( !session && - tracks.some((track) => !track.localId && !track.remoteLibraryId) + tracks.some((track) => !track.localId && !track.remoteLibraryId && !track.audioUrl) ) return false; const casting = castPlayback.connected(); @@ -1214,6 +1224,7 @@ async function enqueueLocalTracks( let index = lastIndex ?? resumeIndex; if (castPlayback.connected()) { const session = castPlayback.sessionGeneration(); + for (const track of tracks) if (track.audioUrl || track.streamUrl?.match(/^https?:/i)) await validateTrackUrl(track, track.audioUrl || track.streamUrl!); const paths = await playbackPaths(queue); if (!castPlayback.connected() || session !== castPlayback.sessionGeneration()) { throw new Error("Chromecast disconnected. Reconnect and try again."); @@ -1302,8 +1313,9 @@ const deviceLabel = (): string => const toRemoteQueueItem = (track: UploadQueueTrack): RemoteQueueItem => ({ // A navidrome song is addressed by its Subsonic id, an upload by its id; // sending the right one is what lets a controller re-enqueue it. + url: track.audioUrl, uploadId: - track.localId || track.navidromeId || track.remoteLibraryId || track.renderer + track.audioUrl || track.localId || track.navidromeId || track.remoteLibraryId || track.renderer ? undefined : track.uploadId, trackId: track.navidromeId, @@ -1314,7 +1326,7 @@ const toRemoteQueueItem = (track: UploadQueueTrack): RemoteQueueItem => ({ albumArt: !track.remoteLibraryId && !track.renderer && - track.albumArt?.startsWith("https://") + (track.albumArt?.startsWith("https://") || track.albumArt?.startsWith("data:image/")) ? track.albumArt : undefined, durationMs: track.durationMs, @@ -1343,7 +1355,7 @@ function advertiseNowPlaying(force = false) { albumArt: !track.remoteLibraryId && !track.renderer && - track.albumArt?.startsWith("https://") + (track.albumArt?.startsWith("https://") || track.albumArt?.startsWith("data:image/")) ? track.albumArt : undefined, durationMs: np.duration, @@ -1387,6 +1399,7 @@ function fromRemoteQueueItem(item: RemoteQueueItem): UploadQueueTrack | null { artistUri: null, sha256: "", }; + if (item.url) return { ...base, uploadId: "external-audio", audioUrl: item.url, streamUrl: item.url }; if (item.trackId && navidromeCreds) { return { ...base, @@ -1408,17 +1421,17 @@ async function handleRemoteEnqueue(cmd: EnqueueCommand) { if (tracks.length === 0) return; const ordered = cmd.shuffle ? shuffleTracks(tracks) : tracks; if (cmd.mode === "next") { - await queueUploadsNext(ordered); + if (!await queueUploadsNext(ordered)) throw new Error("The target player could not add this audio to its queue."); return; } if (cmd.mode === "last") { - await queueUploadsLast(ordered); + if (!await queueUploadsLast(ordered)) throw new Error("The target player could not add this audio to its queue."); return; } const startIndex = cmd.shuffle ? 0 : Math.min(Math.max(0, cmd.startIndex), ordered.length - 1); - await playUploads(ordered, startIndex); + if (!await playUploads(ordered, startIndex)) throw new Error("The target player could not start this audio."); } function shuffleTracks(tracks: UploadQueueTrack[]): UploadQueueTrack[] { @@ -1451,7 +1464,11 @@ export function startLocalRemotePlayer() { .on("previous", () => handleTransport("previous")) .on("seek", (positionMs) => handleTransport("seek", positionMs)) .on("enqueue", (cmd) => { - handleRemoteEnqueue(cmd); + void handleRemoteEnqueue(cmd).catch((error) => { + const message = error instanceof Error ? error.message : "Could not queue audio"; + remotePlayer?.commandError(message); + Alert.alert("Audio URL rejected", message); + }); }) .on("setAudioSettings", (settings) => { engineCommand({ cmd: "setAudioSettings", settings }); @@ -1491,3 +1508,35 @@ export function stopLocalRemotePlayer() { export function localRemoteDeviceId(): string | null { return remotePlayer?.id || null; } + +async function validateTrackUrl(track: UploadQueueTrack, url: string) { + const metadata = await audioUrlTrack(url); + const known = track.uploadId !== "external-audio" && !track.uploadId.startsWith("url:"); + Object.assign(track, { ...metadata, + uploadId: known ? track.uploadId : metadata.uploadId, + title: known ? track.title || metadata.title : metadata.title, + artist: metadata.artist || track.artist, + album: metadata.album || track.album, + albumArtist: metadata.albumArtist || track.albumArtist, + albumArt: metadata.albumArt || track.albumArt, + durationMs: metadata.durationMs || track.durationMs, + songUri: track.songUri, albumUri: track.albumUri, artistUri: track.artistUri, sha256: track.sha256, + scrobbleEligible: track.scrobbleEligible ?? !!(metadata.artist || track.artist), + }); +} + +export async function audioUrlTrack(url: string): Promise { + const metadata = await prepareAudioUrl(url); + return { audioUrl: metadata.url, streamUrl: metadata.url, uploadId: `url:${metadata.cacheId}`, title: metadata.title, + artist: metadata.artist ?? "", album: metadata.album ?? "", albumArtist: metadata.albumArtist || metadata.artist || "", + albumArt: metadata.albumArt ?? null, durationMs: metadata.durationMs ?? 0, mimeType: metadata.mimeType, + scrobbleEligible: !!metadata.artist, songUri: null, albumUri: null, artistUri: null, sha256: "" }; +} +export async function playAudioUrl(url: string, mode: "now" | "next" | "last") { + // Resolution is inside the serialized queue operation, before engine mutation. + const track: UploadQueueTrack = { audioUrl: url, streamUrl: url, uploadId: "external-audio", title: "Audio stream", artist: "", albumArtist: "", album: "", albumArt: null, durationMs: 0, songUri: null, albumUri: null, artistUri: null, sha256: "" }; + const { sendToSelectedPlayer } = await import("./libraryPlayback"); + if (await sendToSelectedPlayer([track], mode)) return; + const accepted = mode === "next" ? await queueUploadsNext([track]) : mode === "last" ? await queueUploadsLast([track]) : await playUploads([track], 0); + if (!accepted) throw new Error("Could not start audio playback. Please try again."); +} diff --git a/apps/app/src/screens/Library/OpenAudioUrl.tsx b/apps/app/src/screens/Library/OpenAudioUrl.tsx new file mode 100644 index 00000000..350f4a16 --- /dev/null +++ b/apps/app/src/screens/Library/OpenAudioUrl.tsx @@ -0,0 +1,145 @@ +import Feather from "@expo/vector-icons/Feather"; +import { useState } from "react"; +import { + ActivityIndicator, + Modal, + Pressable, + TextInput, + View, +} from "react-native"; +import { Text } from "../../components/Text"; +import { playAudioUrl } from "../../lib/uploadEngine"; +import { colors } from "../../theme"; + +export default function OpenAudioUrl() { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const submit = async (mode: "now" | "next" | "last") => { + if (busy || !url.trim()) return; + setBusy(true); + setError(""); + try { + await playAudioUrl(url.trim(), mode); + setOpen(false); + setUrl(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not load audio URL"); + } finally { + setBusy(false); + } + }; + return ( + <> + setOpen(true)} + style={{ + width: 44, + height: 48, + alignItems: "center", + justifyContent: "center", + }} + > + + + { + if (!busy) setOpen(false); + }} + > + + + + Open audio URL + + + {busy && ( + + + + Checking audio and reading tags… + + + )} + {!!error && ( + + {error} + + )} + {( + [ + ["now", "Play now"], + ["next", "Play next"], + ["last", "Add to queue"], + ] as const + ).map(([mode, label]) => ( + void submit(mode)} + style={{ + padding: 12, + borderRadius: 24, + alignItems: "center", + backgroundColor: colors.primary, + opacity: busy || !url.trim() ? 0.5 : 1, + }} + > + + {label} + + + ))} + setOpen(false)} + style={{ padding: 10, alignItems: "center" }} + > + Cancel + + + + + + ); +} diff --git a/apps/app/src/screens/Library/RemoteLibrary.tsx b/apps/app/src/screens/Library/RemoteLibrary.tsx index 3819d0a5..56758e9f 100644 --- a/apps/app/src/screens/Library/RemoteLibrary.tsx +++ b/apps/app/src/screens/Library/RemoteLibrary.tsx @@ -669,6 +669,15 @@ export default function RemoteLibrary({ setMenuTrack(null); }} /> + { + const track = menuTrack; + setMenuTrack(null); + void run(() => playQueue([serverQueueTrack(source.id, track)], 0)); + }} + /> + serde_json::Value { serde_json::json!({ "uploadId": it.upload_id, "trackId": it.track_id, + "url": it.url, "title": it.title, "artist": it.artist, "album": it.album, @@ -1136,6 +1139,7 @@ fn remote_player_set_queue( let items = items .into_iter() .map(|i| rocksky_sdk::RemoteQueueItem { + url: i.url, upload_id: i.upload_id, track_id: i.track_id, title: i.title, @@ -1231,6 +1235,13 @@ fn device_to_json(d: &rocksky_sdk::RemoteDevice) -> serde_json::Value { fn event_to_json(e: &rocksky_sdk::RemoteEvent) -> serde_json::Value { use rocksky_sdk::RemoteEvent as E; match e { + E::CommandError { + device_id, + device_name, + message, + } => { + serde_json::json!({ "type": "commandError", "deviceId": device_id, "deviceName": device_name, "message": message }) + } E::Devices { primary_device, devices, @@ -1476,6 +1487,7 @@ fn remote_controller_enqueue( let items = items .into_iter() .map(|i| rocksky_sdk::RemoteQueueItem { + url: i.url, upload_id: i.upload_id, track_id: i.track_id, title: i.title, diff --git a/crates/rocksky-sdk/examples/remote_control.rs b/crates/rocksky-sdk/examples/remote_control.rs index 763b29cb..94280823 100644 --- a/crates/rocksky-sdk/examples/remote_control.rs +++ b/crates/rocksky-sdk/examples/remote_control.rs @@ -86,6 +86,11 @@ async fn run_controller(token: String) { println!("controller connected — listening for devices (Ctrl-C to quit)…"); while let Some(event) = controller.next_event().await { match event { + RemoteEvent::CommandError { + device_name, + message, + .. + } => eprintln!("{device_name}: {message}"), RemoteEvent::Devices { devices, .. } => { println!("devices: {}", devices.len()); for d in &devices { diff --git a/crates/rocksky-sdk/src/remote_controller.rs b/crates/rocksky-sdk/src/remote_controller.rs index 7d36705b..5593ae1f 100644 --- a/crates/rocksky-sdk/src/remote_controller.rs +++ b/crates/rocksky-sdk/src/remote_controller.rs @@ -90,6 +90,11 @@ pub struct RemoteDevice { /// An update pushed to the controller by the server. #[derive(Clone, Debug)] pub enum RemoteEvent { + CommandError { + device_id: String, + device_name: String, + message: String, + }, /// The full device snapshot (sent right after (re)register). Devices { primary_device: Option, @@ -459,6 +464,11 @@ fn message_event(v: &Value) -> Option { let device_name = str_field(v, "device_name"); let data = v.get("data")?; match data.get("type").and_then(|x| x.as_str())? { + "command_error" => Some(RemoteEvent::CommandError { + device_id, + device_name, + message: str_field(data, "message"), + }), "track" => Some(RemoteEvent::NowPlaying { device_id, device_name, @@ -547,6 +557,7 @@ fn track_from_value(v: &Value) -> RemoteNowPlaying { fn queue_item_from_value(v: &Value) -> RemoteQueueItem { RemoteQueueItem { + url: str_field(v, "url"), upload_id: str_field(v, "uploadId"), track_id: str_field(v, "trackId"), title: str_field(v, "title"), @@ -564,6 +575,7 @@ fn queue_item_from_value(v: &Value) -> RemoteQueueItem { /// An `enqueue` descriptor the controller sends to a player. fn descriptor_json(t: &RemoteQueueItem) -> Value { json!({ + "url": t.url, "uploadId": t.upload_id, "trackId": t.track_id, "title": t.title, @@ -592,3 +604,26 @@ fn str_field(v: &Value, key: &str) -> String { .unwrap_or("") .to_string() } + +#[cfg(test)] +mod audio_url_tests { + use super::*; + #[test] + fn target_error_preserves_device_identity() { + let events = parse_events( + r#"{"type":"message","device_id":"target","device_name":"Bedroom","data":{"type":"command_error","message":"IP unreachable"}}"#, + ); + assert!( + matches!(&events[0], RemoteEvent::CommandError { device_id, message, .. } if device_id == "target" && message == "IP unreachable") + ); + } + #[test] + fn target_queue_preserves_direct_url() { + let events = parse_events( + r#"{"type":"message","device_id":"target","data":{"type":"queue","queue":[{"url":"http://192.0.2.1/music","title":"Audio"}]}}"#, + ); + assert!( + matches!(&events[0], RemoteEvent::Queue { queue, .. } if queue[0].url == "http://192.0.2.1/music") + ); + } +} diff --git a/crates/rocksky-sdk/src/remote_player.rs b/crates/rocksky-sdk/src/remote_player.rs index 294987bc..8aa57b96 100644 --- a/crates/rocksky-sdk/src/remote_player.rs +++ b/crates/rocksky-sdk/src/remote_player.rs @@ -124,6 +124,8 @@ impl RemoteStatus { /// (Navidrome/Subsonic id) so the item can be streamed. #[derive(Clone, Debug, Default)] pub struct RemoteQueueItem { + /// Direct external HTTP(S) audio, validated by the receiving player. + pub url: String, pub upload_id: String, pub track_id: String, pub title: String, @@ -186,6 +188,7 @@ pub enum RemoteCommand { // Outbound state pushes carried from the handle to the background task, which // frames them with the (post-register) device_id + token before sending. enum OutMsg { + CommandError(String), Track(RemoteNowPlaying), Status(i32), Queue(Vec, u32), @@ -252,6 +255,10 @@ impl RemotePlayer { } /// Advertise the playback queue + current index. + pub fn command_error(&self, message: String) { + let _ = self.out_tx.send(OutMsg::CommandError(message)); + } + pub fn set_queue(&self, items: Vec, index: u32) { let _ = self.out_tx.send(OutMsg::Queue(items, index)); } @@ -392,6 +399,7 @@ async fn session( fn remember(last: &mut LastState, msg: &OutMsg) { match msg { + OutMsg::CommandError(_) => {} OutMsg::Track(t) => last.track = Some(t.clone()), OutMsg::Status(s) => last.status = Some(*s), OutMsg::Queue(q, i) => last.queue = Some((q.clone(), *i)), @@ -408,6 +416,9 @@ where S: SinkExt + Unpin, { let data = match msg { + OutMsg::CommandError(message) => { + json!({ "type": "command_error", "action": "enqueue", "message": message }) + } OutMsg::Track(t) => { let mut data = json!({ "type": "track", @@ -462,6 +473,7 @@ where fn queue_item_json(t: &RemoteQueueItem) -> serde_json::Value { json!({ + "url": t.url, "uploadId": t.upload_id, "trackId": t.track_id, "title": t.title, @@ -588,6 +600,7 @@ fn parse_command(msg: &Inbound) -> Option { fn descriptor_to_item(v: &serde_json::Value) -> RemoteQueueItem { let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); RemoteQueueItem { + url: s("url"), upload_id: s("uploadId"), track_id: s("trackId"), title: s("title"), @@ -615,3 +628,20 @@ struct Inbound { #[serde(default)] args: Option, } + +#[cfg(test)] +mod audio_url_tests { + use super::*; + #[test] + fn direct_url_survives_queue_serialization() { + let item = RemoteQueueItem { + url: "http://192.0.2.1:8200/audio/42".into(), + ..Default::default() + }; + let wire = queue_item_json(&item); + assert_eq!(descriptor_to_item(&wire).url, item.url); + assert!(descriptor_to_item(&json!({"uploadId": "legacy"})) + .url + .is_empty()); + } +} diff --git a/crates/rocksky-uniffi/src/capi.rs b/crates/rocksky-uniffi/src/capi.rs index fe10b159..f5eef551 100644 --- a/crates/rocksky-uniffi/src/capi.rs +++ b/crates/rocksky-uniffi/src/capi.rs @@ -682,6 +682,8 @@ impl Default for NowPlayingJson { #[derive(serde::Deserialize, Default)] #[serde(rename_all = "camelCase", default)] struct QueueItemJson { + #[serde(default)] + url: String, upload_id: String, track_id: String, title: String, @@ -704,6 +706,7 @@ fn queue_item_to_json(it: &rocksky_sdk::RemoteQueueItem) -> serde_json::Value { serde_json::json!({ "uploadId": it.upload_id, "trackId": it.track_id, + "url": it.url, "title": it.title, "artist": it.artist, "album": it.album, @@ -893,6 +896,7 @@ pub unsafe extern "C" fn rocksky_remote_player_set_queue( let items = items .into_iter() .map(|i| rocksky_sdk::RemoteQueueItem { + url: i.url, upload_id: i.upload_id, track_id: i.track_id, title: i.title, @@ -1001,6 +1005,13 @@ fn device_to_json(d: &rocksky_sdk::RemoteDevice) -> serde_json::Value { fn event_to_json(e: &rocksky_sdk::RemoteEvent) -> serde_json::Value { use rocksky_sdk::RemoteEvent as E; match e { + E::CommandError { + device_id, + device_name, + message, + } => { + serde_json::json!({ "type": "commandError", "deviceId": device_id, "deviceName": device_name, "message": message }) + } E::Devices { primary_device, devices, @@ -1330,6 +1341,7 @@ pub unsafe extern "C" fn rocksky_remote_controller_enqueue( let items = items .into_iter() .map(|i| rocksky_sdk::RemoteQueueItem { + url: i.url, upload_id: i.upload_id, track_id: i.track_id, title: i.title, diff --git a/crates/rocksky-uniffi/src/lib.rs b/crates/rocksky-uniffi/src/lib.rs index 29ce8c04..d86eae37 100644 --- a/crates/rocksky-uniffi/src/lib.rs +++ b/crates/rocksky-uniffi/src/lib.rs @@ -1948,6 +1948,8 @@ pub enum RemoteStatus { #[cfg(feature = "remote-player")] #[derive(Debug, Clone, Default, uniffi::Record)] pub struct RemoteQueueItem { + #[uniffi(default = "")] + pub url: String, #[uniffi(default = "")] pub upload_id: String, #[uniffi(default = "")] @@ -2057,6 +2059,7 @@ impl From for rocksky_sdk::RemoteStatus { impl From for rocksky_sdk::RemoteQueueItem { fn from(v: RemoteQueueItem) -> Self { Self { + url: v.url, upload_id: v.upload_id, track_id: v.track_id, title: v.title, @@ -2076,6 +2079,7 @@ impl From for rocksky_sdk::RemoteQueueItem { impl From for RemoteQueueItem { fn from(v: rocksky_sdk::RemoteQueueItem) -> Self { Self { + url: v.url, upload_id: v.upload_id, track_id: v.track_id, title: v.title, @@ -2202,6 +2206,11 @@ pub struct RemoteDevice { #[cfg(feature = "remote-player")] #[derive(Debug, Clone, uniffi::Enum)] pub enum RemoteEvent { + CommandError { + device_id: String, + device_name: String, + message: String, + }, Devices { primary_device: Option, devices: Vec, @@ -2284,6 +2293,15 @@ impl From for RemoteEvent { fn from(v: rocksky_sdk::RemoteEvent) -> Self { use rocksky_sdk::RemoteEvent as E; match v { + E::CommandError { + device_id, + device_name, + message, + } => Self::CommandError { + device_id, + device_name, + message, + }, E::Devices { primary_device, devices, diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index e075084e..3d9cab0a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -75,6 +75,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1613,6 +1619,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.5.0" @@ -3328,6 +3340,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "log" version = "0.4.34" @@ -4001,6 +4039,15 @@ dependencies = [ "cc", ] +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4127,6 +4174,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pcsc" version = "2.9.0" @@ -4968,6 +5021,7 @@ dependencies = [ "reqwest 0.12.28", "rockbox-metadata", "rockbox-playback", + "rocksky-metadata", "rocksky-sdk", "serde", "serde_json", @@ -4981,6 +5035,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "rocksky-metadata" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "lofty", + "reqwest 0.12.28", + "rockbox-metadata", + "serde_json", + "sha2", + "symphonia", + "tempfile", +] + [[package]] name = "rocksky-sdk" version = "0.12.0" @@ -5770,6 +5838,175 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 276531da..b3e4f9bc 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +rocksky-metadata = { path = "../../apps/app/native/metadata" } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6323af2b..c7e19ecd 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -84,6 +84,7 @@ pub fn run() { player::player_set_compressor, player::player_set_surround, player::player_enqueue, + player::player_prepare_audio_url, player::player_play, player::player_pause, player::player_toggle, diff --git a/desktop/src-tauri/src/player.rs b/desktop/src-tauri/src/player.rs index d95d8c82..c277322b 100644 --- a/desktop/src-tauri/src/player.rs +++ b/desktop/src-tauri/src/player.rs @@ -182,6 +182,8 @@ pub fn player_queue_paths(state: State<'_, AppState>) -> Vec { #[derive(serde::Deserialize, Default)] #[serde(rename_all = "camelCase", default)] pub struct QueueMetaDto { + #[serde(default)] + pub url: String, pub upload_id: String, pub track_id: String, pub title: String, @@ -198,6 +200,7 @@ pub struct QueueMetaDto { impl From for RemoteQueueItem { fn from(d: QueueMetaDto) -> Self { RemoteQueueItem { + url: d.url, upload_id: d.upload_id, track_id: d.track_id, title: d.title, @@ -441,3 +444,35 @@ pub fn player_queue(state: State<'_, AppState>) -> Vec { }) .collect() } + +/// Resolve and validate media off the UI thread before accepting a queue change. +#[tauri::command] +pub async fn player_prepare_audio_url( + app: tauri::AppHandle, + url: String, +) -> Result { + use tauri::Manager; + let directory = app + .path() + .app_cache_dir() + .map_err(|_| "Audio cache unavailable")? + .join("audio-urls"); + tokio::task::spawn_blocking(move || rocksky_metadata::audio_url::prepare(&url, &directory)) + .await + .map_err(|_| "Audio validation failed".to_string())? +} + +pub fn external_queue_item(m: &serde_json::Value) -> RemoteQueueItem { + let s = |k: &str| m[k].as_str().unwrap_or("").to_string(); + RemoteQueueItem { + url: s("url"), + title: s("title"), + artist: s("artist"), + album: s("album"), + album_artist: s("albumArtist"), + album_art: s("albumArt"), + duration_ms: m["durationMs"].as_u64().unwrap_or(0), + track_number: m["trackNumber"].as_i64().unwrap_or(0) as i32, + ..Default::default() + } +} diff --git a/desktop/src-tauri/src/remote.rs b/desktop/src-tauri/src/remote.rs index 1e951cd3..fc231d15 100644 --- a/desktop/src-tauri/src/remote.rs +++ b/desktop/src-tauri/src/remote.rs @@ -182,12 +182,13 @@ pub async fn remote_connect( let http = reqwest::Client::new(); let mut stream_token: Option<(String, std::time::Instant)> = None; while let Some(cmd) = remote.next_command().await { - if matches!(cmd, RemoteCommand::Enqueue { .. }) { + if matches!(&cmd, RemoteCommand::Enqueue { tracks, .. } if tracks.iter().any(|t| t.url.is_empty() && !t.upload_id.is_empty())) + { stream_token = ensure_stream_token(&http, &api_url, &token, stream_token.take()).await; } let st = stream_token.as_ref().map(|(t, _)| t.as_str()).unwrap_or(""); - apply(&app, &remote, cmd, &api_url, st); + apply(&app, &remote, cmd, &api_url, st).await; } tracing::info!("remote command loop ended"); }); @@ -251,7 +252,7 @@ fn disconnect_inner(state: &AppState) { } /// Apply one controller command to the local engine. -fn apply( +async fn apply( app: &AppHandle, remote: &RemotePlayer, cmd: RemoteCommand, @@ -287,10 +288,28 @@ fn apply( shuffle, start_index, } => { - let playable: Vec<(RemoteQueueItem, String)> = tracks - .into_iter() - .filter_map(|t| resolve_uri(app, &t, api_url, stream_token).map(|uri| (t, uri))) - .collect(); + let mut playable = Vec::new(); + for track in tracks { + if !track.url.is_empty() { + match crate::player::player_prepare_audio_url(app.clone(), track.url.clone()) + .await + { + Ok(metadata) => { + let item = crate::player::external_queue_item(&metadata); + playable.push((item.clone(), item.url)); + } + Err(error) => { + remote.command_error(format!( + "Cannot play audio URL on this device: {error}" + )); + tracing::warn!(%error, "audio URL rejected; queue unchanged"); + return; + } + } + } else if let Some(uri) = resolve_uri(app, &track, api_url, stream_token) { + playable.push((track, uri)); + } + } if playable.is_empty() { tracing::warn!( stream_token_present = !stream_token.is_empty(), diff --git a/desktop/src/atoms/queue.ts b/desktop/src/atoms/queue.ts index d6cbdcc7..d1434d17 100644 --- a/desktop/src/atoms/queue.ts +++ b/desktop/src/atoms/queue.ts @@ -1,6 +1,7 @@ import { atom } from "jotai"; export interface QueueTrack { + audioUrl?: string; uploadId: string; title: string; artist: string; diff --git a/desktop/src/components/OpenAudioUrl.tsx b/desktop/src/components/OpenAudioUrl.tsx new file mode 100644 index 00000000..4d2cdf03 --- /dev/null +++ b/desktop/src/components/OpenAudioUrl.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { Modal, ModalBody, ModalHeader } from "baseui/modal"; +import { IconLink } from "@tabler/icons-react"; +import { useUploadPlayer } from "../hooks/useUploadPlayer"; +import type { QueueTrack } from "../atoms/queue"; +import { isTauri } from "../lib/tauri"; + +type Metadata = { + url: string; + cacheId: string; + title: string; + artist?: string; + album?: string; + albumArtist?: string; + albumArt?: string; + durationMs?: number; + mimeType?: string; +}; +export default function OpenAudioUrl() { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const { playNow, playNext, playLast } = useUploadPlayer(); + const submit = async (mode: "now" | "next" | "last") => { + if (busy || !url.trim()) return; + setBusy(true); + setError(""); + try { + const m: Metadata = { + url: url.trim(), + cacheId: url.trim(), + title: "Audio stream", + }; + const track: QueueTrack = { + uploadId: `url:${m.cacheId}`, + audioUrl: m.url, + streamUrl: m.url, + title: m.title, + artist: m.artist ?? "", + albumArtist: m.albumArtist || m.artist || "", + album: m.album ?? "", + albumArt: m.albumArt ?? null, + duration: m.durationMs ?? 0, + sha256: "", + songUri: "", + mimeType: m.mimeType, + }; + if (mode === "now") await playNow([track], 0); + else if (mode === "next") await playNext(track); + else await playLast(track); + setOpen(false); + setUrl(""); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + if (!isTauri()) return null; + return ( + <> + + { + if (!busy) setOpen(false); + }} + closeable={!busy} + > + Open audio URL + + + {busy &&

Checking audio and reading tags…

} + {error &&

{error}

} +
+ {( + [ + ["now", "Play now"], + ["next", "Play next"], + ["last", "Add to queue"], + ] as const + ).map(([mode, label]) => ( + + ))} +
+
+
+ + ); +} diff --git a/desktop/src/components/StickyPlayer/StickyPlayerWithData.tsx b/desktop/src/components/StickyPlayer/StickyPlayerWithData.tsx index 115d0873..0968828a 100644 --- a/desktop/src/components/StickyPlayer/StickyPlayerWithData.tsx +++ b/desktop/src/components/StickyPlayer/StickyPlayerWithData.tsx @@ -170,7 +170,9 @@ function toRemoteNowPlaying( // renders. function toRemoteQueueTrack(q: SdkRemoteQueueItem) { return { - uploadId: q.uploadId || q.trackId || "", + audioUrl: q.url, + streamUrl: q.url, + uploadId: q.uploadId || q.trackId || q.url || "", title: q.title ?? "", artist: q.albumArtist || q.artist || "", albumArtist: q.albumArtist ?? "", @@ -576,6 +578,7 @@ function StickyPlayerWithData() { } }) // A player pushed its queue → mirror it (kept per-device for the panel). + .on("commandError", ({ deviceName, message }) => window.alert(`${deviceName || "Player"}: ${message}`)) .on("queue", ({ deviceId, deviceName, index, queue }) => { if (!deviceId || isSelfDevice(deviceId, deviceName)) return; const q = Array.isArray(queue) ? queue.map(toRemoteQueueTrack) : []; diff --git a/desktop/src/hooks/useRockboxEngine.ts b/desktop/src/hooks/useRockboxEngine.ts index e42cc22e..ff052667 100644 --- a/desktop/src/hooks/useRockboxEngine.ts +++ b/desktop/src/hooks/useRockboxEngine.ts @@ -44,7 +44,8 @@ setQueueMetaResolver((url: string) => { const t = trackForUrl(url); if (!t) return undefined; return { - uploadId: t.uploadId, + url: t.audioUrl, + uploadId: t.audioUrl ? undefined : t.uploadId, title: t.title, artist: t.artist, album: t.album, diff --git a/desktop/src/hooks/useUploadPlayer.ts b/desktop/src/hooks/useUploadPlayer.ts index 8e44ae8c..6619fbd8 100644 --- a/desktop/src/hooks/useUploadPlayer.ts +++ b/desktop/src/hooks/useUploadPlayer.ts @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { useAtomValue, useSetAtom } from "jotai"; import { useCallback } from "react"; import { InsertMode } from "rockbox-wasm"; @@ -19,7 +20,8 @@ import { // (= the CLI's trackId), so the remote player streams them via Navidrome. function toDescriptor(t: QueueTrack) { return { - trackId: t.uploadId, + url: t.audioUrl || t.streamUrl, + trackId: t.audioUrl || t.streamUrl ? undefined : t.uploadId, title: t.title, artist: t.artist, album: t.album, @@ -30,6 +32,38 @@ function toDescriptor(t: QueueTrack) { }; } +async function validateOnThisPlayer(tracks: QueueTrack[]) { + // Called only after remote routing: reachability on a controller is irrelevant. + const resolved = await Promise.all( + tracks.map(async (track) => { + const url = track.audioUrl || track.streamUrl; + if (!url || !/^https?:/i.test(url)) return track; + const m = await invoke<{ + url: string; + cacheId: string; + title: string; + artist?: string; + album?: string; + albumArtist?: string; + albumArt?: string; + durationMs?: number; + }>("player_prepare_audio_url", { url }); + return { + ...track, + audioUrl: m.url, + streamUrl: m.url, + title: m.title || track.title, + artist: m.artist || track.artist, + album: m.album || track.album, + albumArtist: m.albumArtist || track.albumArtist, + albumArt: m.albumArt || track.albumArt, + duration: m.durationMs || track.duration, + }; + }), + ); + return resolved; +} + // useUploadPlayer — orchestrates the in-browser rockbox-wasm queue. // // Playback runs entirely in the browser now (decoders + DSP in wasm). There is @@ -78,7 +112,7 @@ export function useUploadPlayer() { // starts a playlist; a queue we build and hand over is played in the // order we give it. So the toggle has to be applied here, or starting an // album, a playlist or an NFC tag would ignore it entirely. - const tracks = shuffle + let tracks = shuffle ? from === undefined ? shuffled(input) : shuffledFrom(input, from) @@ -93,6 +127,7 @@ export function useUploadPlayer() { }); return; } + tracks = await validateOnThisPlayer(tracks); // Optimistic now-playing so the sticky player reacts instantly; the // engine's track/status events reconcile the rest. setNowPlaying(trackToNowPlaying(tracks[startIndex])); @@ -103,7 +138,7 @@ export function useUploadPlayer() { // token) lands outside the gesture, and Chrome then rejects the worklet // load with "Unable to load a worklet's module". const p = await ensureRockboxReady(); - await ensureStreamToken(); + if (tracks.some((track) => !track.streamUrl)) await ensureStreamToken(); registerTracks(tracks); const urls = tracks.map(streamUrlFor); @@ -123,53 +158,81 @@ export function useUploadPlayer() { [setNowPlaying, setPlayer, deviceCommand, shuffle], ); - const playNext = useCallback(async (track: QueueTrack) => { - if (deviceCommand.active) { - deviceCommand.send("enqueue", { tracks: [toDescriptor(track)], mode: "next" }); - return; - } - const p = await ensureRockboxReady(); - await ensureStreamToken(); - registerTracks([track]); - p.insert(streamUrlFor(track), InsertMode.PlayNext); - }, [deviceCommand]); + const playNext = useCallback( + async (track: QueueTrack) => { + if (deviceCommand.active) { + deviceCommand.send("enqueue", { + tracks: [toDescriptor(track)], + mode: "next", + }); + return; + } + track = (await validateOnThisPlayer([track]))[0]; + const p = await ensureRockboxReady(); + if (!track.streamUrl) await ensureStreamToken(); + registerTracks([track]); + p.insert(streamUrlFor(track), InsertMode.PlayNext); + }, + [deviceCommand], + ); - const playNextAll = useCallback(async (tracks: QueueTrack[]) => { - if (!tracks.length) return; - if (deviceCommand.active) { - deviceCommand.send("enqueue", { tracks: tracks.map(toDescriptor), mode: "next" }); - return; - } - const p = await ensureRockboxReady(); - await ensureStreamToken(); - registerTracks(tracks); - // insert() with an array keeps the batch's order directly after the - // current track (Rockbox's PlayNext), so no manual reversal is needed. - p.insert(tracks.map(streamUrlFor), InsertMode.PlayNext); - }, [deviceCommand]); + const playNextAll = useCallback( + async (tracks: QueueTrack[]) => { + if (!tracks.length) return; + if (deviceCommand.active) { + deviceCommand.send("enqueue", { + tracks: tracks.map(toDescriptor), + mode: "next", + }); + return; + } + tracks = await validateOnThisPlayer(tracks); + const p = await ensureRockboxReady(); + if (tracks.some((track) => !track.streamUrl)) await ensureStreamToken(); + registerTracks(tracks); + // insert() with an array keeps the batch's order directly after the + // current track (Rockbox's PlayNext), so no manual reversal is needed. + p.insert(tracks.map(streamUrlFor), InsertMode.PlayNext); + }, + [deviceCommand], + ); - const playLast = useCallback(async (track: QueueTrack) => { - if (deviceCommand.active) { - deviceCommand.send("enqueue", { tracks: [toDescriptor(track)], mode: "last" }); - return; - } - const p = await ensureRockboxReady(); - await ensureStreamToken(); - registerTracks([track]); - p.insert(streamUrlFor(track), InsertMode.PlayLast); - }, [deviceCommand]); + const playLast = useCallback( + async (track: QueueTrack) => { + if (deviceCommand.active) { + deviceCommand.send("enqueue", { + tracks: [toDescriptor(track)], + mode: "last", + }); + return; + } + track = (await validateOnThisPlayer([track]))[0]; + const p = await ensureRockboxReady(); + if (!track.streamUrl) await ensureStreamToken(); + registerTracks([track]); + p.insert(streamUrlFor(track), InsertMode.PlayLast); + }, + [deviceCommand], + ); - const playLastAll = useCallback(async (tracks: QueueTrack[]) => { - if (!tracks.length) return; - if (deviceCommand.active) { - deviceCommand.send("enqueue", { tracks: tracks.map(toDescriptor), mode: "last" }); - return; - } - const p = await ensureRockboxReady(); - await ensureStreamToken(); - registerTracks(tracks); - p.insert(tracks.map(streamUrlFor), InsertMode.PlayLast); - }, [deviceCommand]); + const playLastAll = useCallback( + async (tracks: QueueTrack[]) => { + if (!tracks.length) return; + if (deviceCommand.active) { + deviceCommand.send("enqueue", { + tracks: tracks.map(toDescriptor), + mode: "last", + }); + return; + } + tracks = await validateOnThisPlayer(tracks); + const p = await ensureRockboxReady(); + if (tracks.some((track) => !track.streamUrl)) await ensureStreamToken(); + registerTracks(tracks); + p.insert(tracks.map(streamUrlFor), InsertMode.PlayLast); + }, + [deviceCommand], + ); return { queue, diff --git a/desktop/src/lib/tauri-rockbox/index.ts b/desktop/src/lib/tauri-rockbox/index.ts index fc41265a..550908a5 100644 --- a/desktop/src/lib/tauri-rockbox/index.ts +++ b/desktop/src/lib/tauri-rockbox/index.ts @@ -14,6 +14,7 @@ import { invoke } from "@tauri-apps/api/core"; // the URLs. No resolver installed → unchanged behaviour. export interface QueueMeta { + url?: string; uploadId?: string; trackId?: string; title?: string; @@ -39,6 +40,7 @@ function metaFor(urls: string[]): Record[] { return urls.map((url) => { const t = queueMetaResolver?.(url); return { + url: t?.url ?? "", uploadId: t?.uploadId ?? "", trackId: t?.trackId ?? "", title: t?.title ?? "", diff --git a/desktop/src/pages/library/Library.tsx b/desktop/src/pages/library/Library.tsx index 4cd8cec6..d8230065 100644 --- a/desktop/src/pages/library/Library.tsx +++ b/desktop/src/pages/library/Library.tsx @@ -1,3 +1,4 @@ +import OpenAudioUrl from "../../components/OpenAudioUrl"; import styled from "@emotion/styled"; import { shuffled } from "../../lib/shuffle"; import { @@ -1225,6 +1226,7 @@ export default function Library() {
My Library + navigate({ to: "/library/upload" })}> Upload Music diff --git a/flake.nix b/flake.nix index 1b7c0342..d99b8190 100644 --- a/flake.nix +++ b/flake.nix @@ -79,6 +79,10 @@ repository = "https://github.com/tsirysndr/rocksky" ''; + # Shared native URL validation/tags are path dependencies of both players. + metadataSource = fs.difference ./apps/app/native/metadata + (fs.maybeMissing ./apps/app/native/metadata/target); + playerd = rustPlatform.buildRustPackage { pname = "playerd"; version = "0.3.0"; @@ -89,6 +93,7 @@ (fs.unions [ ./playerd ./crates/rocksky-sdk + metadataSource ]) (fs.unions [ ./playerd/npm @@ -341,6 +346,9 @@ fileset = fs.unions [ ./package.json ./bun.lock + # Relative imports retain the repository layout, including shared + # story/analytics/session/legal code and their image/CSS assets. + ./apps/shared (fs.difference ./sdk/typescript (fs.unions [ (fs.maybeMissing ./sdk/typescript/node_modules) (fs.maybeMissing ./sdk/typescript/dist) @@ -401,6 +409,7 @@ (fs.maybeMissing ./desktop/src-tauri/gen) ])) ./crates/rocksky-sdk + metadataSource ]; }; diff --git a/playerd/Cargo.lock b/playerd/Cargo.lock index 39d8c594..f7971a34 100644 --- a/playerd/Cargo.lock +++ b/playerd/Cargo.lock @@ -125,6 +125,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-compression" version = "0.4.43" @@ -2519,6 +2525,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "log" version = "0.4.34" @@ -2970,6 +3002,15 @@ dependencies = [ "cc", ] +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3135,6 +3176,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3270,11 +3317,12 @@ dependencies = [ "reqwest", "rockbox-metadata", "rockbox-playback", + "rocksky-metadata", "rocksky-sdk", "serde", "serde_json", "sqlx", - "symphonia", + "symphonia 0.6.1", "tokio", "toml", "tracing", @@ -3771,6 +3819,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "rocksky-metadata" +version = "0.1.0" +dependencies = [ + "base64", + "lofty", + "reqwest", + "rockbox-metadata", + "serde_json", + "sha2", + "symphonia 0.5.5", + "tempfile", +] + [[package]] name = "rocksky-sdk" version = "0.12.0" @@ -4428,6 +4490,27 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac 0.5.5", + "symphonia-bundle-mp3 0.5.5", + "symphonia-codec-aac 0.5.5", + "symphonia-codec-adpcm 0.5.5", + "symphonia-codec-alac 0.5.5", + "symphonia-codec-pcm 0.5.5", + "symphonia-codec-vorbis 0.5.5", + "symphonia-core 0.5.5", + "symphonia-format-isomp4 0.5.5", + "symphonia-format-ogg 0.5.5", + "symphonia-format-riff 0.5.5", + "symphonia-metadata 0.5.5", +] + [[package]] name = "symphonia" version = "0.6.1" @@ -4435,18 +4518,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" dependencies = [ "lazy_static", - "symphonia-bundle-flac", - "symphonia-bundle-mp3", - "symphonia-codec-aac", - "symphonia-codec-adpcm", - "symphonia-codec-alac", - "symphonia-codec-pcm", - "symphonia-codec-vorbis", - "symphonia-core", - "symphonia-format-isomp4", - "symphonia-format-ogg", - "symphonia-format-riff", - "symphonia-metadata", + "symphonia-bundle-flac 0.6.1", + "symphonia-bundle-mp3 0.6.1", + "symphonia-codec-aac 0.6.1", + "symphonia-codec-adpcm 0.6.1", + "symphonia-codec-alac 0.6.1", + "symphonia-codec-pcm 0.6.1", + "symphonia-codec-vorbis 0.6.1", + "symphonia-core 0.6.1", + "symphonia-format-isomp4 0.6.1", + "symphonia-format-ogg 0.6.1", + "symphonia-format-riff 0.6.1", + "symphonia-metadata 0.6.1", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", + "symphonia-utils-xiph", ] [[package]] @@ -4457,8 +4552,20 @@ checksum = "6405d5c34ff6f8f7ca08a4101efe66d21e180f7322ef9359900a0e55698a7892" dependencies = [ "log", "symphonia-common", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.6.1", + "symphonia-metadata 0.6.1", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", ] [[package]] @@ -4469,7 +4576,18 @@ checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add" dependencies = [ "lazy_static", "log", - "symphonia-core", + "symphonia-core 0.6.1", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core 0.5.5", ] [[package]] @@ -4481,7 +4599,17 @@ dependencies = [ "lazy_static", "log", "symphonia-common", - "symphonia-core", + "symphonia-core 0.6.1", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core 0.5.5", ] [[package]] @@ -4491,7 +4619,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.6.1", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core 0.5.5", ] [[package]] @@ -4502,7 +4640,17 @@ checksum = "920a78f96f3cf62932d0c497959c0cc2100ef36b03c7ca1417b260f432a482d8" dependencies = [ "log", "symphonia-common", - "symphonia-core", + "symphonia-core 0.6.1", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core 0.5.5", ] [[package]] @@ -4512,7 +4660,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.6.1", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core 0.5.5", + "symphonia-utils-xiph", ] [[package]] @@ -4523,7 +4682,7 @@ checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986" dependencies = [ "log", "symphonia-common", - "symphonia-core", + "symphonia-core 0.6.1", ] [[package]] @@ -4533,8 +4692,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" dependencies = [ "log", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.6.1", + "symphonia-metadata 0.6.1", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", ] [[package]] @@ -4551,6 +4723,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", + "symphonia-utils-xiph", +] + [[package]] name = "symphonia-format-isomp4" version = "0.6.1" @@ -4559,8 +4744,20 @@ checksum = "0e681a70e1870d34e02abf1dbc51e4267c3f1827801474e8870be8c689fc4dc3" dependencies = [ "log", "symphonia-common", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.6.1", + "symphonia-metadata 0.6.1", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", + "symphonia-utils-xiph", ] [[package]] @@ -4571,8 +4768,20 @@ checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994" dependencies = [ "log", "symphonia-common", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.6.1", + "symphonia-metadata 0.6.1", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", ] [[package]] @@ -4583,8 +4792,20 @@ checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" dependencies = [ "extended", "log", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.6.1", + "symphonia-metadata 0.6.1", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core 0.5.5", ] [[package]] @@ -4597,7 +4818,17 @@ dependencies = [ "log", "regex-lite", "smallvec", - "symphonia-core", + "symphonia-core 0.6.1", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", ] [[package]] diff --git a/playerd/Cargo.toml b/playerd/Cargo.toml index 2158c526..ef6e0b2b 100644 --- a/playerd/Cargo.toml +++ b/playerd/Cargo.toml @@ -12,6 +12,7 @@ description = "Rocksky remote player daemon — a headless rockbox-playback devi [workspace] [dependencies] +rocksky-metadata = { path = "../apps/app/native/metadata" } anyhow = "1" clap = { version = "4", features = ["derive", "env"] } dirs = "6" diff --git a/playerd/src/main.rs b/playerd/src/main.rs index bd2e120b..eb322dab 100644 --- a/playerd/src/main.rs +++ b/playerd/src/main.rs @@ -33,7 +33,7 @@ use crate::resolver::Resolver; struct Cli { #[command(subcommand)] command: Option, - /// Audio files or directories to queue and play at startup + /// Audio files, directories, or HTTP(S) audio URLs to validate and play at startup paths: Vec, /// TOML config file (default: ~/.rocksky/playerd.toml) #[arg(short, long, env = "PLAYERD_CONFIG", global = true)] @@ -119,7 +119,21 @@ async fn main() -> Result<()> { return mcp::run(config, token).await; } - let (uris, items) = resolver::scan_local(&cli.paths)?; + let mut uris = Vec::new(); + let mut items = Vec::new(); + for path in &cli.paths { + let value = path.to_string_lossy(); + if value.starts_with("http://") || value.starts_with("https://") { + let (item, uri) = resolver::external_audio(value.into_owned()).await?; + uris.push(uri); + items.push(item); + } else { + let (mut local_uris, mut local_items) = + resolver::scan_local(std::slice::from_ref(path))?; + uris.append(&mut local_uris); + items.append(&mut local_items); + } + } let mut player_config = config.player_config()?; if let Some(spec) = config diff --git a/playerd/src/mcp/state.rs b/playerd/src/mcp/state.rs index 1f059545..baada698 100644 --- a/playerd/src/mcp/state.rs +++ b/playerd/src/mcp/state.rs @@ -32,6 +32,7 @@ pub struct DeviceState { pub status: Option, pub queue: Vec, pub queue_index: u32, + pub last_error: Option, } /// Where a command is addressed. @@ -237,6 +238,14 @@ impl Player { shuffle: bool, start_index: u32, ) { + { + let mut world = self.world.lock().unwrap(); + for device in world.devices.values_mut() { + if matches!(t, Target::All) || t.id().as_deref() == Some(&device.id) { + device.last_error = None; + } + } + } self.controller .enqueue(t.id(), tracks, mode, shuffle, start_index); } @@ -272,6 +281,13 @@ async fn pump(player: std::sync::Arc) { while let Some(event) = player.controller.next_event().await { let mut world = player.world.lock().unwrap(); match event { + RemoteEvent::CommandError { + device_id, + device_name, + message, + } => { + upsert(&mut world, &device_id, &device_name).last_error = Some(message); + } RemoteEvent::Devices { primary_device, devices, @@ -296,6 +312,7 @@ async fn pump(player: std::sync::Arc) { status, queue: d.queue, queue_index: d.queue_index, + last_error: None, }, ) }) @@ -408,6 +425,9 @@ pub fn queue_item_json(index: usize, item: &RemoteQueueItem, current: bool) -> V "album": item.album, "durationMs": item.duration_ms, }); + if !item.url.is_empty() { + v["url"] = json!(item.url); + } if current { v["current"] = json!(true); } @@ -424,6 +444,7 @@ pub fn queue_item_json(index: usize, item: &RemoteQueueItem, current: bool) -> V /// caller in JSON it did not ask for. pub fn device_json(device: &DeviceState, primary: bool, full_queue: bool) -> Value { let mut v = json!({ + "lastError": device.last_error, "id": device.id, "name": device.name, "status": status_str(device.status), diff --git a/playerd/src/mcp/subsonic.rs b/playerd/src/mcp/subsonic.rs index 0d5dad5e..ebe2ff65 100644 --- a/playerd/src/mcp/subsonic.rs +++ b/playerd/src/mcp/subsonic.rs @@ -24,6 +24,7 @@ pub struct Subsonic { /// talk about it, plus the `id` that makes it playable. #[derive(Clone, Default)] pub struct Song { + pub url: String, pub id: String, pub title: String, pub artist: String, @@ -42,6 +43,7 @@ pub struct Song { impl Song { fn from_value(v: &Value) -> Song { Song { + url: String::new(), id: str_of(v, "id"), title: str_of(v, "title"), artist: str_of(v, "artist"), @@ -64,6 +66,7 @@ impl Song { pub fn to_json(&self) -> Value { let mut v = json!({ "id": self.id, + "url": self.url, "title": self.title, "artist": self.artist, "album": self.album, @@ -92,6 +95,7 @@ impl Song { /// streams it back from Navidrome with its own credentials. pub fn to_queue_item(&self) -> RemoteQueueItem { RemoteQueueItem { + url: self.url.clone(), track_id: self.id.clone(), title: self.title.clone(), artist: self.artist.clone(), diff --git a/playerd/src/mcp/tools/player.rs b/playerd/src/mcp/tools/player.rs index c0063d7d..3142c42e 100644 --- a/playerd/src/mcp/tools/player.rs +++ b/playerd/src/mcp/tools/player.rs @@ -99,15 +99,16 @@ pub fn definitions() -> Vec { ), tool( "enqueue", - "Put music on a player. Supply tracks (by library id, or just title + artist and they get matched against the library), or an album_id, or a playlist_id. This is how you start a set, add to it, or slip one song in next.", + "Put music on a player. Supply tracks (by direct audio URL, library id, or just title + artist and they get matched against the library), or an album_id, or a playlist_id. This is how you start a set, add to it, or slip one song in next.", json!({ "device": device_prop(), "tracks": { "type": "array", - "description": "Tracks to queue, in order. Each entry is either {\"id\": \"\"} or {\"title\": \"...\", \"artist\": \"...\"} — names are resolved against the library, and anything with no match is reported back as unresolved instead of failing the call.", + "description": "Tracks to queue, in order. Each entry can contain a direct HTTP(S) audio URL in `url`, or {\"id\": \"\"} or {\"title\": \"...\", \"artist\": \"...\"} — names are resolved against the library, and anything with no match is reported back as unresolved instead of failing the call.", "items": { "type": "object", "properties": { + "url": { "type": "string", "description": "Direct HTTP(S) audio URL; fetched, validated and tagged by the target player before queueing." }, "id": { "type": "string", "description": "Library track id (from search_library / get_album / browse_songs)." }, "title": { "type": "string" }, "artist": { "type": "string" }, @@ -314,6 +315,12 @@ pub async fn call(ctx: &Ctx, name: &str, args: &Args<'_>) -> Result>(), "state": state_of(ctx, &target, false)?, }); + if songs.iter().any(|song| !song.url.is_empty()) { + if let Some(requested) = v.as_object_mut().unwrap().remove("enqueued") { + v["requested"] = requested; + } + v["note"] = json!("Sent to the target player for network and audio validation. Acceptance is confirmed only when its queue contains the URL; inspect lastError for a target-side rejection."); + } if !unresolved.is_empty() { v["unresolved"] = json!(unresolved); v["note"] = json!("These had no match in the library and were skipped. Try search_library with a looser query."); @@ -400,11 +407,20 @@ async fn collect_tracks(ctx: &Ctx, args: &Args<'_>) -> Result<(Vec, Vec { let mut playable: Vec<(RemoteQueueItem, String)> = Vec::with_capacity(tracks.len()); for track in tracks { + if !track.url.is_empty() { + match crate::resolver::external_audio(track.url.clone()).await { + Ok(pair) => playable.push(pair), + Err(error) => { + shared.remote.command_error(format!( + "Cannot play audio URL on this device: {error}" + )); + tracing::warn!(%error, "audio URL rejected; queue unchanged"); + return; + } + } + continue; + } match resolver.resolve(&track).await { Some(uri) => playable.push((track, uri)), None => tracing::warn!( diff --git a/playerd/src/resolver.rs b/playerd/src/resolver.rs index 5fa64572..7eefc1e0 100644 --- a/playerd/src/resolver.rs +++ b/playerd/src/resolver.rs @@ -110,6 +110,12 @@ impl Resolver { } pub async fn resolve(&mut self, item: &RemoteQueueItem) -> Option { + if !item.url.is_empty() { + return external_audio(item.url.clone()) + .await + .ok() + .map(|(_, url)| url); + } if !item.upload_id.is_empty() { let token = self.stream_token().await?; return Some(format!( @@ -238,3 +244,29 @@ fn collect(path: &Path, out: &mut Vec) -> Result<()> { } Ok(()) } + +pub async fn external_audio(url: String) -> Result<(RemoteQueueItem, String)> { + let directory = dirs::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("rocksky/playerd/audio-urls"); + let m = + tokio::task::spawn_blocking(move || rocksky_metadata::audio_url::prepare(&url, &directory)) + .await? + .map_err(anyhow::Error::msg)?; + let s = |key: &str| m[key].as_str().unwrap_or("").to_string(); + let url = s("url"); + Ok(( + RemoteQueueItem { + url: url.clone(), + title: s("title"), + artist: s("artist"), + album: s("album"), + album_artist: s("albumArtist"), + album_art: s("albumArt"), + duration_ms: m["durationMs"].as_u64().unwrap_or(0), + track_number: m["trackNumber"].as_i64().unwrap_or(0) as i32, + ..Default::default() + }, + url, + )) +} diff --git a/playerd/src/resume.rs b/playerd/src/resume.rs index 2f559df0..22194860 100644 --- a/playerd/src/resume.rs +++ b/playerd/src/resume.rs @@ -39,6 +39,7 @@ use crate::resolver::Resolver; #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct SavedItem { + pub url: String, pub upload_id: String, pub track_id: String, pub title: String, @@ -55,6 +56,7 @@ pub struct SavedItem { impl From<&RemoteQueueItem> for SavedItem { fn from(i: &RemoteQueueItem) -> Self { SavedItem { + url: i.url.clone(), upload_id: i.upload_id.clone(), track_id: i.track_id.clone(), title: i.title.clone(), @@ -73,6 +75,7 @@ impl From<&RemoteQueueItem> for SavedItem { impl From<&SavedItem> for RemoteQueueItem { fn from(s: &SavedItem) -> Self { RemoteQueueItem { + url: s.url.clone(), upload_id: s.upload_id.clone(), track_id: s.track_id.clone(), title: s.title.clone(), diff --git a/remote-ws/PROTOCOL.md b/remote-ws/PROTOCOL.md index 94d0f763..f4937e92 100644 --- a/remote-ws/PROTOCOL.md +++ b/remote-ws/PROTOCOL.md @@ -112,7 +112,7 @@ followed by a `devices` snapshot (§5.1). ### 4.2 `message` — push now-playing / status / queue (player devices) -Wrap a state payload. `data.type` is one of `track`, `status`, `queue`. +Wrap a state payload. `data.type` is one of `track`, `status`, `queue`, `command_error`. ```json { "type": "message", "device_id": "", "token": "", @@ -442,3 +442,25 @@ thread. The Rust, TS, and Go SDKs also offer a handler/callback style. - **Web controller:** `apps/web/src/components/StickyPlayer/StickyPlayerWithData.tsx` - **Mobile controller:** `apps/web-mobile/src/components/MiniPlayer/index.tsx` - **Server:** `remote-ws/` (see `README.md` for the module map) + +### Direct audio URLs and target validation + +An enqueue descriptor may carry `url` with an HTTP(S) audio URL instead of an +`uploadId` or `trackId`. Controllers resolve library references to URLs but do not +use their own network reachability as proof that a player can play them. The +selected target fetches the URL, follows bounded redirects, checks HTTP status, +and decodes audio before changing its queue. Connection/DNS failures, unreachable +IPs, timeouts, HTTP errors, and unsupported media reject the batch without +changing playback. Targets extract and cache available tags and embedded artwork. +Queue pushes preserve the `url` field. + +A target reports rejection through a device message: + +```json +{"type":"command_error","action":"enqueue","message":"The audio host/IP is unreachable from this player"} +``` + +The relay attaches the registered target's device ID and broadcasts the error to +its account's controllers without changing playback status. Sending an enqueue +request is not an acceptance acknowledgment; the target's resulting queue confirms +acceptance. This extension requires a player version that supports direct URLs. diff --git a/remote-ws/lib/remote_ws/ws/handler.ex b/remote-ws/lib/remote_ws/ws/handler.ex index 0f140cc6..27e4249c 100644 --- a/remote-ws/lib/remote_ws/ws/handler.ex +++ b/remote-ws/lib/remote_ws/ws/handler.ex @@ -206,6 +206,9 @@ defmodule RemoteWs.Ws.Handler do "queue" -> NowPlaying.handle_queue(did, device_id, data) + "command_error" -> + data + _ -> NowPlaying.handle_status(did, device_id, data) data diff --git a/remote-ws/test/remote_ws/ws/handler_test.exs b/remote-ws/test/remote_ws/ws/handler_test.exs index dd0a4b8b..17de9fba 100644 --- a/remote-ws/test/remote_ws/ws/handler_test.exs +++ b/remote-ws/test/remote_ws/ws/handler_test.exs @@ -35,6 +35,20 @@ defmodule RemoteWs.Ws.HandlerTest do assert ann["clientName"] == "Rocksky CLI" end + test "target URL rejection reaches controllers without changing playback status" do + did = new_did() + token = Token.sign(%{"did" => did}) + controller = FakeDevice.start(did, "controller", "Controller", self()) + RedisMemory.put("primary_device:#{did}", "target") + RedisMemory.put("nowplaying:#{did}:status", "1") + data = %{"type" => "command_error", "action" => "enqueue", "message" => "Host/IP unreachable"} + Handler.handle(%{"type" => "message", "token" => token, "data" => data}, %{device_id: "target", did: did}) + assert_receive {:pushed, ^controller, frame} + assert Jason.decode!(frame)["device_id"] == "target" + assert Jason.decode!(frame)["data"] == data + assert RedisMemory.get("nowplaying:#{did}:status") == "1" + end + test "command with target routes to one device; no target broadcasts to all" do did = new_did() token = Token.sign(%{"did" => did}) diff --git a/rocksky-mcp/src/state.rs b/rocksky-mcp/src/state.rs index b2eefc44..e2ea5d10 100644 --- a/rocksky-mcp/src/state.rs +++ b/rocksky-mcp/src/state.rs @@ -32,6 +32,7 @@ pub struct DeviceState { pub status: Option, pub queue: Vec, pub queue_index: u32, + pub last_error: Option, } /// Where a command is addressed. @@ -276,6 +277,13 @@ async fn pump(player: std::sync::Arc) { while let Some(event) = player.controller.next_event().await { let mut world = player.world.lock().unwrap(); match event { + RemoteEvent::CommandError { + device_id, + device_name, + message, + } => { + upsert(&mut world, &device_id, &device_name).last_error = Some(message); + } RemoteEvent::Devices { primary_device, devices, @@ -300,6 +308,7 @@ async fn pump(player: std::sync::Arc) { status, queue: d.queue, queue_index: d.queue_index, + last_error: None, }, ) }) @@ -428,6 +437,7 @@ pub fn queue_item_json(index: usize, item: &RemoteQueueItem, current: bool) -> V /// caller in JSON it did not ask for. pub fn device_json(device: &DeviceState, primary: bool, full_queue: bool) -> Value { let mut v = json!({ + "lastError": device.last_error, "id": device.id, "name": device.name, "status": status_str(device.status), diff --git a/sdk/typescript/src/remote-controller.ts b/sdk/typescript/src/remote-controller.ts index 0fc3945c..959dfdac 100644 --- a/sdk/typescript/src/remote-controller.ts +++ b/sdk/typescript/src/remote-controller.ts @@ -44,6 +44,7 @@ export type RemoteStatus = "playing" | "paused" | "stopped"; /** Events the controller emits (from the server). */ export interface RemoteControllerEvents { + commandError(event: { deviceId: string; deviceName: string; message: string }): void; /** The full device snapshot (on connect + reconnect). */ devices(payload: { primaryDevice: string | null; devices: RemoteDevice[] }): void; deviceRegistered(payload: { deviceId: string; name: string }): void; @@ -177,9 +178,11 @@ export class RemoteController { shuffle = false, startIndex = 0, ): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error("The player connection is offline."); this.command("enqueue", target, { tracks: tracks.map((t) => ({ - uploadId: t.uploadId, + url: t.url, + uploadId: t.uploadId, trackId: t.trackId, title: t.title, artist: t.artist, @@ -340,6 +343,9 @@ export class RemoteController { const data = msg.data; if (!data) return; switch (data.type) { + case "command_error": + this.handlers.commandError?.({ deviceId, deviceName, message: data.message ?? "The player rejected this request." }); + break; case "track": this.handlers.nowPlaying?.({ deviceId, deviceName, track: trackFromJson(data) }); break; @@ -391,6 +397,7 @@ function trackFromJson(d: Json): RemoteNowPlaying { function queueItemFromJson(d: Json): RemoteQueueItem { return { + url: d.url, uploadId: d.uploadId, trackId: d.trackId, title: d.title ?? "", diff --git a/sdk/typescript/src/remote-player.ts b/sdk/typescript/src/remote-player.ts index 0c378a87..21874954 100644 --- a/sdk/typescript/src/remote-player.ts +++ b/sdk/typescript/src/remote-player.ts @@ -153,6 +153,8 @@ export interface RemoteAudioSettings { /** One queue entry. `uploadId` (Rocksky uploads) or `trackId` (Navidrome id). */ export interface RemoteQueueItem { + /** External audio URL; the receiving player validates it before queueing. */ + url?: string; uploadId?: string; trackId?: string; title: string; @@ -307,6 +309,11 @@ export class RemotePlayer { }); } + commandError(message: string): void { + this.send({ type: "message", device_id: this.deviceId, token: this.getToken(), + data: { type: "command_error", action: "enqueue", message } }); + } + /** Advertise transport state. */ setStatus(status: "playing" | "paused" | "stopped"): void { const code = status === "playing" ? 1 : status === "paused" ? 2 : 0; @@ -330,6 +337,7 @@ export class RemotePlayer { type: "queue", index, queue: items.map((t) => ({ + url: t.url, uploadId: t.uploadId, trackId: t.trackId, title: t.title, @@ -518,6 +526,7 @@ export class RemotePlayer { function descriptorToItem(d: Json): RemoteQueueItem { return { + url: d.url, uploadId: d.uploadId, trackId: d.trackId, title: d.title ?? "", -- knotmirror 0.0.1