added player
This commit is contained in:
+2
-1
@@ -2,4 +2,5 @@
|
||||
*.sqlite3
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
*federation/
|
||||
*.federation/
|
||||
.cargo/config.toml
|
||||
|
||||
Generated
+1
@@ -996,6 +996,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
+4
-3
@@ -7,9 +7,9 @@ description = "Localhost music library index manager: REST API over a local SQLi
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
# P2P federation: publishes the library index into a DHT and searches other
|
||||
# peers' libraries. Local path dependency on the frid workspace.
|
||||
music-dht = { path = "../../frid/crates/music-dht" }
|
||||
# P2P federation: publishes the library index into a DHT, searches other
|
||||
# peers' libraries and streams audio between peers.
|
||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -17,3 +17,4 @@ chrono = "0.4"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
@@ -21,6 +21,7 @@ cargo run
|
||||
| `FURUMI_FD_DB` | `furumi-fd.sqlite3` | Path to the SQLite database file (created if missing) |
|
||||
| `FURUMI_FD_LISTEN` | `127.0.0.1:8321` | Listen address |
|
||||
| `FURUMI_FD_FEDERATION_DIR` | `<db>.federation` | Directory for the federation identity and DHT state |
|
||||
| `FURUMI_FD_MEDIA_ROOT` | *(unset)* | Root of the audio library; `media_files.file_path` resolves against it. Unset ⇒ playback is disabled, the index API keeps working. Files are only ever **read**. |
|
||||
|
||||
No authentication — localhost only by default.
|
||||
|
||||
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
//! Audio playback support.
|
||||
//!
|
||||
//! Local tracks are served over HTTP (with Range support) straight from the
|
||||
//! media root; federated tracks are streamed from their owner over a raw
|
||||
//! `music-dht` byte stream on the [`AUDIO_ALPN`] protocol. Files are only
|
||||
//! ever read, never written or deleted.
|
||||
//!
|
||||
//! The peer protocol is deliberately simple: the requester sends one JSON
|
||||
//! line ([`AudioRequest`]), the owner answers with one JSON line
|
||||
//! ([`AudioResponseHeader`]) followed by the raw file bytes from the
|
||||
//! requested offset.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::OnceLock;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, ReadBuf};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
|
||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||
|
||||
/// Maximum size of a JSON protocol line (request or response header).
|
||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||
|
||||
/// Root directory of the local audio library (`FURUMI_FD_MEDIA_ROOT`).
|
||||
///
|
||||
/// `media_files.file_path` values are resolved relative to it. `None` when
|
||||
/// the variable is not set — playback is then disabled, the index API keeps
|
||||
/// working.
|
||||
pub fn media_root() -> Option<&'static Path> {
|
||||
static ROOT: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
ROOT.get_or_init(|| {
|
||||
std::env::var("FURUMI_FD_MEDIA_ROOT")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
fn media_root_required() -> ApiResult<&'static Path> {
|
||||
media_root().ok_or_else(|| {
|
||||
ApiError::BadRequest(
|
||||
"media root is not configured; set FURUMI_FD_MEDIA_ROOT to enable playback"
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local track resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A playable local track: where its audio lives and what it is.
|
||||
pub struct LocalAudio {
|
||||
pub path: PathBuf,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
fn guess_mime(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"mp3" => "audio/mpeg",
|
||||
"flac" => "audio/flac",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"opus" => "audio/opus",
|
||||
"wav" => "audio/wav",
|
||||
"m4a" | "mp4" | "alac" => "audio/mp4",
|
||||
"aac" => "audio/aac",
|
||||
"aiff" | "aif" => "audio/aiff",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn local_audio(file_path: &str, mime_type: &str) -> ApiResult<LocalAudio> {
|
||||
let root = media_root_required()?;
|
||||
let relative = file_path.trim().trim_start_matches('/');
|
||||
if relative.is_empty() || relative.split('/').any(|part| part == "..") {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"media file has an unsafe path: {file_path}"
|
||||
)));
|
||||
}
|
||||
let path = root.join(relative);
|
||||
let mime = if mime_type.trim().is_empty() {
|
||||
guess_mime(&path).to_string()
|
||||
} else {
|
||||
mime_type.to_string()
|
||||
};
|
||||
Ok(LocalAudio {
|
||||
path,
|
||||
mime_type: mime,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a local track id to its audio file on disk.
|
||||
pub async fn resolve_local_track(pool: &SqlitePool, track_id: i64) -> ApiResult<LocalAudio> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM tracks t
|
||||
JOIN media_files m ON m.id = t.audio_file_id
|
||||
WHERE t.id = ?",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::not_found("track", track_id))?;
|
||||
local_audio(&row.get::<String, _>(0), &row.get::<String, _>(1))
|
||||
}
|
||||
|
||||
/// Resolves a federated [`ItemId`] back to a local track of this instance.
|
||||
///
|
||||
/// The id is derived from the owner and the track's local key, so the owner
|
||||
/// can always recompute it for its own library.
|
||||
pub async fn resolve_item(
|
||||
pool: &SqlitePool,
|
||||
own: EndpointId,
|
||||
item_id: ItemId,
|
||||
) -> ApiResult<Option<LocalAudio>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT t.id, m.file_path, m.mime_type FROM tracks t
|
||||
JOIN media_files m ON m.id = t.audio_file_id
|
||||
WHERE t.is_hidden = 0",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let track_id: i64 = row.get(0);
|
||||
let derived = ItemId::derive(&own, ItemKind::Track, &format!("track:{track_id}"));
|
||||
if derived == item_id {
|
||||
return Ok(Some(local_audio(
|
||||
&row.get::<String, _>(1),
|
||||
&row.get::<String, _>(2),
|
||||
)?));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP Range handling and responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extracts the start offset of a `Range` header.
|
||||
///
|
||||
/// Playback only ever needs `bytes=N-` (browsers seek by requesting an open
|
||||
/// range); an explicit end is accepted but the response still runs to the
|
||||
/// end of the file.
|
||||
pub fn range_start(headers: &HeaderMap) -> Option<u64> {
|
||||
let value = headers.get(header::RANGE)?.to_str().ok()?;
|
||||
let spec = value.strip_prefix("bytes=")?;
|
||||
let start = spec.split(['-', ',']).next()?;
|
||||
start.parse().ok()
|
||||
}
|
||||
|
||||
fn audio_response(
|
||||
body: Body,
|
||||
mime_type: &str,
|
||||
total_size: u64,
|
||||
offset: u64,
|
||||
ranged: bool,
|
||||
) -> ApiResult<Response> {
|
||||
if offset > 0 && offset >= total_size {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{total_size}"))
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
let mut builder = Response::builder()
|
||||
.header(header::CONTENT_TYPE, mime_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, total_size - offset)
|
||||
.header(header::CACHE_CONTROL, "no-store");
|
||||
builder = if ranged {
|
||||
builder.status(StatusCode::PARTIAL_CONTENT).header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {offset}-{}/{total_size}", total_size - 1),
|
||||
)
|
||||
} else {
|
||||
builder.status(StatusCode::OK)
|
||||
};
|
||||
builder
|
||||
.body(body)
|
||||
.map_err(|err| ApiError::Internal(err.into()))
|
||||
}
|
||||
|
||||
/// Serves a local audio file over HTTP, honoring an optional Range offset.
|
||||
pub async fn serve_local(local: LocalAudio, headers: &HeaderMap) -> ApiResult<Response> {
|
||||
let mut file = tokio::fs::File::open(&local.path).await.map_err(|err| {
|
||||
ApiError::NotFound(format!(
|
||||
"audio file {} is not readable: {err}",
|
||||
local.path.display()
|
||||
))
|
||||
})?;
|
||||
let total_size = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|err| ApiError::Internal(err.into()))?
|
||||
.len();
|
||||
let offset = range_start(headers);
|
||||
let start = offset.unwrap_or(0).min(total_size);
|
||||
if start > 0 {
|
||||
file.seek(std::io::SeekFrom::Start(start))
|
||||
.await
|
||||
.map_err(|err| ApiError::Internal(err.into()))?;
|
||||
}
|
||||
audio_response(
|
||||
Body::from_stream(ReaderStream::new(file)),
|
||||
&local.mime_type,
|
||||
total_size,
|
||||
offset.unwrap_or(0),
|
||||
offset.is_some(),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Peer-to-peer audio protocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// First (and only) line sent by the requesting peer.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AudioRequest {
|
||||
/// Hex-encoded [`ItemId`] of the track, as returned by federated search.
|
||||
item_id: String,
|
||||
/// Byte offset to start streaming from (Range passthrough).
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
/// First line sent back by the owner; raw file bytes follow on success.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AudioResponseHeader {
|
||||
ok: bool,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
mime_type: String,
|
||||
#[serde(default)]
|
||||
total_size: u64,
|
||||
#[serde(default)]
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
pub fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn hex_decode_item_id(value: &str) -> Option<ItemId> {
|
||||
if value.len() != 64 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
for (i, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(ItemId::from_bytes(bytes))
|
||||
}
|
||||
|
||||
/// Reads one `\n`-terminated line from the stream, bounded by
|
||||
/// [`MAX_PROTOCOL_LINE`].
|
||||
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> anyhow::Result<Vec<u8>> {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = reader.read(&mut byte).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("stream ended before the protocol line was complete");
|
||||
}
|
||||
if byte[0] == b'\n' {
|
||||
return Ok(line);
|
||||
}
|
||||
line.push(byte[0]);
|
||||
if line.len() > MAX_PROTOCOL_LINE {
|
||||
anyhow::bail!("protocol line exceeds {MAX_PROTOCOL_LINE} bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_line<W: AsyncWriteExt + Unpin>(
|
||||
writer: &mut W,
|
||||
value: &impl Serialize,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut line = serde_json::to_vec(value)?;
|
||||
line.push(b'\n');
|
||||
writer.write_all(&line).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serving side: answer audio requests from other peers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Runs the accept loop of the audio protocol until the acceptor closes.
|
||||
///
|
||||
/// Every visible (non-hidden) track of the local index is downloadable by
|
||||
/// every peer of the network — the libraries of all participants are equal.
|
||||
pub async fn serve_peers(mut acceptor: StreamAcceptor, pool: SqlitePool, own: EndpointId) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_one(stream, pool, own).await {
|
||||
tracing::warn!(peer = %peer, "audio stream failed: {err:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(mut stream: ByteStream, pool: SqlitePool, own: EndpointId) -> anyhow::Result<()> {
|
||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
item = %request.item_id,
|
||||
offset = request.offset,
|
||||
"peer requested audio"
|
||||
);
|
||||
|
||||
let resolved = match hex_decode_item_id(&request.item_id) {
|
||||
Some(item_id) => match resolve_item(&pool, own, item_id).await {
|
||||
Ok(local) => local.ok_or_else(|| "track not found in the library".to_string()),
|
||||
Err(err) => Err(format!("library lookup failed: {err:?}")),
|
||||
},
|
||||
None => Err("malformed item_id".to_string()),
|
||||
};
|
||||
let local = match resolved {
|
||||
Ok(local) => local,
|
||||
Err(message) => return refuse(stream, message).await,
|
||||
};
|
||||
|
||||
let mut file = match tokio::fs::File::open(&local.path).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => return refuse(stream, format!("audio file is not readable: {err}")).await,
|
||||
};
|
||||
let total_size = file.metadata().await?.len();
|
||||
let offset = request.offset.min(total_size);
|
||||
if offset > 0 {
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
}
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: true,
|
||||
error: None,
|
||||
mime_type: local.mime_type,
|
||||
total_size,
|
||||
offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tokio::io::copy(&mut file, &mut stream.send).await?;
|
||||
stream.send.finish()?;
|
||||
// Wait until the peer read everything (or gave up) before dropping the
|
||||
// stream, otherwise the tail of the file is lost.
|
||||
let _ = stream.send.stopped().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a refusal header and waits until the peer read it before dropping
|
||||
/// the stream (and with it the connection).
|
||||
async fn refuse(mut stream: ByteStream, message: String) -> anyhow::Result<()> {
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioResponseHeader {
|
||||
ok: false,
|
||||
error: Some(message.clone()),
|
||||
mime_type: String::new(),
|
||||
total_size: 0,
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
anyhow::bail!("refused audio request: {message}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requesting side: proxy a remote track to the local browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Adapter that keeps the whole [`ByteStream`] (and with it the underlying
|
||||
/// connection) alive while the HTTP response body is being streamed.
|
||||
struct StreamBody(ByteStream);
|
||||
|
||||
impl AsyncRead for StreamBody {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.0.recv).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a track from `owner` over the federation and returns it as an
|
||||
/// HTTP response, passing the Range offset through to the peer.
|
||||
pub async fn serve_remote(
|
||||
service: &MusicDhtService,
|
||||
owner: EndpointId,
|
||||
item_id_hex: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> ApiResult<Response> {
|
||||
let offset = range_start(headers);
|
||||
let mut stream = service
|
||||
.open_stream(owner, AUDIO_ALPN)
|
||||
.await
|
||||
.map_err(|err| ApiError::BadRequest(format!("cannot reach the owner peer: {err}")))?;
|
||||
write_line(
|
||||
&mut stream.send,
|
||||
&AudioRequest {
|
||||
item_id: item_id_hex.to_string(),
|
||||
offset: offset.unwrap_or(0),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
stream
|
||||
.send
|
||||
.finish()
|
||||
.map_err(|err| ApiError::Internal(err.into()))?;
|
||||
let header_line = read_line(&mut stream.recv).await.map_err(ApiError::Internal)?;
|
||||
let header: AudioResponseHeader =
|
||||
serde_json::from_slice(&header_line).map_err(|err| ApiError::Internal(err.into()))?;
|
||||
if !header.ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"peer refused the stream: {}",
|
||||
header.error.unwrap_or_else(|| "unknown error".to_string())
|
||||
)));
|
||||
}
|
||||
let mime = if header.mime_type.is_empty() {
|
||||
"application/octet-stream".to_string()
|
||||
} else {
|
||||
header.mime_type
|
||||
};
|
||||
audio_response(
|
||||
Body::from_stream(ReaderStream::new(StreamBody(stream))),
|
||||
&mime,
|
||||
header.total_size,
|
||||
header.offset,
|
||||
offset.is_some(),
|
||||
)
|
||||
}
|
||||
+96
-2
@@ -19,7 +19,8 @@ use axum::extract::{Query, State};
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use music_dht::{
|
||||
ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, RendezvousConfig,
|
||||
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
|
||||
PeerTicket, RendezvousConfig,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -134,6 +135,8 @@ impl Federation {
|
||||
.network_id(NetworkId::from_name(&network_name))
|
||||
// Peers of the network find each other knowing only its name.
|
||||
.rendezvous(RendezvousConfig::default())
|
||||
// Peers stream each other's audio over this protocol.
|
||||
.stream_protocol(crate::audio::AUDIO_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
let (service, mut events) = MusicDhtService::start(config)
|
||||
@@ -146,6 +149,16 @@ impl Federation {
|
||||
"federation started"
|
||||
);
|
||||
|
||||
// Serve audio requests from other peers of the network.
|
||||
let audio_acceptor = service
|
||||
.stream_acceptor(crate::audio::AUDIO_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the audio acceptor: {err}"))?;
|
||||
let audio_task = tokio::spawn(crate::audio::serve_peers(
|
||||
audio_acceptor,
|
||||
self.pool.clone(),
|
||||
service.endpoint_id(),
|
||||
));
|
||||
|
||||
// Drain DHT events into the log; the channel is bounded and must be
|
||||
// consumed.
|
||||
let event_task = tokio::spawn(async move {
|
||||
@@ -167,7 +180,7 @@ impl Federation {
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
network_name,
|
||||
tasks: vec![event_task, sync_task],
|
||||
tasks: vec![event_task, sync_task, audio_task],
|
||||
});
|
||||
self.set_error(None);
|
||||
Ok(())
|
||||
@@ -366,6 +379,7 @@ async fn collect_library(pool: &SqlitePool) -> anyhow::Result<Vec<ItemSpec>> {
|
||||
|
||||
fn item_to_json(item: &LibraryItem) -> Value {
|
||||
json!({
|
||||
"id": crate::audio::hex_encode(item.id.as_bytes()),
|
||||
"kind": item.kind.as_str(),
|
||||
"name": item.name,
|
||||
"artist_names": item.artist_names,
|
||||
@@ -387,6 +401,86 @@ pub fn router() -> Router<Arc<Federation>> {
|
||||
.route("/federation/settings", put(put_settings))
|
||||
.route("/federation/search", get(search))
|
||||
.route("/federation/sync", post(sync_now))
|
||||
.route("/federation/stream", get(stream_remote))
|
||||
.route("/federation/ticket", get(get_ticket))
|
||||
.route("/federation/connect", post(connect_ticket))
|
||||
}
|
||||
|
||||
impl Federation {
|
||||
/// A clone of the running DHT service, or an error when federation is
|
||||
/// off.
|
||||
async fn service(&self) -> ApiResult<Arc<MusicDhtService>> {
|
||||
let guard = self.running.lock().await;
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|running| running.service.clone())
|
||||
.ok_or_else(|| ApiError::BadRequest("federation is not running".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StreamParams {
|
||||
/// Endpoint id of the owning peer, as returned by federated search.
|
||||
owner: String,
|
||||
/// Hex item id of the track, as returned by federated search.
|
||||
item_id: String,
|
||||
}
|
||||
|
||||
/// Plays a federated track: proxies the audio bytes from the owning peer.
|
||||
/// A track owned by this very instance is served straight from disk.
|
||||
async fn stream_remote(
|
||||
State(fed): State<Arc<Federation>>,
|
||||
Query(params): Query<StreamParams>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> ApiResult<axum::response::Response> {
|
||||
let service = fed.service().await?;
|
||||
let owner: EndpointId = params
|
||||
.owner
|
||||
.parse()
|
||||
.map_err(|_| ApiError::BadRequest(format!("malformed owner id '{}'", params.owner)))?;
|
||||
if owner == service.endpoint_id() {
|
||||
let item_id = crate::audio::hex_decode_item_id(¶ms.item_id)
|
||||
.ok_or_else(|| ApiError::BadRequest("malformed item_id".to_string()))?;
|
||||
let local = crate::audio::resolve_item(&fed.pool, owner, item_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("track not found in the library".to_string()))?;
|
||||
return crate::audio::serve_local(local, &headers).await;
|
||||
}
|
||||
crate::audio::serve_remote(&service, owner, ¶ms.item_id, &headers).await
|
||||
}
|
||||
|
||||
/// Returns this peer's connection ticket (manual peering, e.g. between two
|
||||
/// local instances without waiting for rendezvous discovery).
|
||||
async fn get_ticket(State(fed): State<Arc<Federation>>) -> ApiResult<Json<Value>> {
|
||||
let service = fed.service().await?;
|
||||
let ticket = service
|
||||
.ticket()
|
||||
.await
|
||||
.map_err(|err| ApiError::BadRequest(format!("cannot create a ticket: {err}")))?;
|
||||
Ok(Json(json!({ "ticket": ticket.to_string() })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConnectBody {
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
/// Connects to another peer by its ticket.
|
||||
async fn connect_ticket(
|
||||
State(fed): State<Arc<Federation>>,
|
||||
Json(body): Json<ConnectBody>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let service = fed.service().await?;
|
||||
let ticket: PeerTicket = body
|
||||
.ticket
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|err| ApiError::BadRequest(format!("malformed ticket: {err}")))?;
|
||||
let peer = service
|
||||
.connect(ticket)
|
||||
.await
|
||||
.map_err(|err| ApiError::BadRequest(format!("connect failed: {err}")))?;
|
||||
Ok(Json(json!({ "connected": peer.to_string() })))
|
||||
}
|
||||
|
||||
async fn get_status(State(fed): State<Arc<Federation>>) -> ApiResult<Json<Value>> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod audio;
|
||||
mod error;
|
||||
mod federation;
|
||||
mod routes;
|
||||
@@ -32,6 +33,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
.await?;
|
||||
schema::init(&pool).await?;
|
||||
tracing::info!("database ready at {db_path}");
|
||||
match audio::media_root() {
|
||||
Some(root) => tracing::info!("media root: {} (read-only)", root.display()),
|
||||
None => tracing::warn!(
|
||||
"FURUMI_FD_MEDIA_ROOT is not set — playback is disabled, the index API works as usual"
|
||||
),
|
||||
}
|
||||
|
||||
// Federation state (identity, DHT replicas) lives outside the main db.
|
||||
let federation_dir = std::env::var("FURUMI_FD_FEDERATION_DIR")
|
||||
|
||||
@@ -13,6 +13,18 @@ pub fn router() -> Router<SqlitePool> {
|
||||
.route("/{id}", get(get_one).patch(update).delete(delete_one))
|
||||
.route("/{id}/artists", put(set_artists))
|
||||
.route("/{id}/genres", put(set_genres))
|
||||
.route("/{id}/audio", get(stream_audio))
|
||||
}
|
||||
|
||||
/// Streams the track's audio file from the local media root (read-only,
|
||||
/// with Range support for seeking).
|
||||
async fn stream_audio(
|
||||
State(pool): State<SqlitePool>,
|
||||
Path(id): Path<i64>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> ApiResult<axum::response::Response> {
|
||||
let local = crate::audio::resolve_local_track(&pool, id).await?;
|
||||
crate::audio::serve_local(local, &headers).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
|
||||
+66
-7
@@ -61,10 +61,18 @@
|
||||
td.actions { text-align: right; white-space: nowrap; }
|
||||
td .muted { color: var(--muted); }
|
||||
#toast {
|
||||
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--danger); color: #fff; padding: 10px 18px; border-radius: 8px;
|
||||
display: none; max-width: 80vw;
|
||||
display: none; max-width: 80vw; z-index: 10;
|
||||
}
|
||||
#player {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 9;
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 8px 20px; background: var(--panel); border-top: 1px solid var(--border);
|
||||
}
|
||||
#player-title { min-width: 200px; max-width: 40vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#player audio { flex: 1; max-width: 640px; height: 36px; }
|
||||
main { padding-bottom: 90px; }
|
||||
#toast.ok { background: #3f9d5f; }
|
||||
dialog {
|
||||
background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
@@ -76,7 +84,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>furumi-fd</h1><span>менеджер музыкальной библиотеки (только индекс — файлы не трогает)</span></header>
|
||||
<header><h1>furumi-fd</h1><span>менеджер музыкальной библиотеки (индекс + прослушивание; файлы никогда не изменяются)</span></header>
|
||||
<nav id="tabs">
|
||||
<button data-tab="artists" class="active">Артисты</button>
|
||||
<button data-tab="releases">Релизы</button>
|
||||
@@ -190,12 +198,17 @@
|
||||
<span id="fed-search-meta" style="color:var(--muted);align-self:center"></span>
|
||||
</div>
|
||||
<table><thead><tr>
|
||||
<th>Тип</th><th>Название</th><th>Артисты</th><th class="num">Год</th><th>Детали</th><th>Владелец</th>
|
||||
<th>Тип</th><th>Название</th><th>Артисты</th><th class="num">Год</th><th>Детали</th><th>Владелец</th><th></th>
|
||||
</tr></thead><tbody id="fed-results-body"></tbody></table>
|
||||
<div class="empty" id="fed-results-empty" hidden>Ничего не найдено</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="player" hidden>
|
||||
<span id="player-title" class="muted"></span>
|
||||
<audio id="player-audio" controls></audio>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
<dialog id="detail-dialog">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
@@ -250,13 +263,55 @@ async function showDetail(title, path) {
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
|
||||
function rowButtons(kind, id) {
|
||||
return `<td class="actions">
|
||||
function rowButtons(kind, id, extra = '') {
|
||||
return `<td class="actions">${extra}
|
||||
<button class="btn secondary small" onclick="viewItem('${kind}', ${id})">инфо</button>
|
||||
<button class="btn danger" onclick="deleteItem('${kind}', ${id})">удалить</button>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
function escAttr(value) {
|
||||
return esc(value).replaceAll('"', '"');
|
||||
}
|
||||
|
||||
// ---------- player ----------
|
||||
function playUrl(url, title) {
|
||||
document.getElementById('player').hidden = false;
|
||||
document.getElementById('player-title').textContent = title;
|
||||
const audio = document.getElementById('player-audio');
|
||||
audio.src = url;
|
||||
audio.play().catch(err => toast('Не удалось запустить воспроизведение: ' + err.message));
|
||||
}
|
||||
|
||||
document.getElementById('player-audio').addEventListener('error', async () => {
|
||||
const audio = document.getElementById('player-audio');
|
||||
if (!audio.src) return;
|
||||
// The stream endpoint reports problems as JSON; surface them.
|
||||
try {
|
||||
const res = await fetch(audio.src, { headers: { range: 'bytes=0-' } });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast(data.error || `Ошибка воспроизведения (${res.status})`);
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through to the generic message */ }
|
||||
toast('Ошибка воспроизведения: формат не поддерживается браузером или поток оборвался');
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const local = event.target.closest('button.play-track');
|
||||
if (local) {
|
||||
playUrl(`${API}/tracks/${local.dataset.id}/audio`, local.dataset.title);
|
||||
return;
|
||||
}
|
||||
const fed = event.target.closest('button.play-fed');
|
||||
if (fed) {
|
||||
const params = new URLSearchParams({ owner: fed.dataset.owner, item_id: fed.dataset.item });
|
||||
const from = fed.dataset.own === '1' ? 'своя библиотека' : `пир ${fed.dataset.owner.slice(0, 12)}…`;
|
||||
playUrl(`${API}/federation/stream?${params}`, `${fed.dataset.title} — ${from}`);
|
||||
}
|
||||
});
|
||||
|
||||
const KIND_PATHS = { artist: 'artists', release: 'releases', track: 'tracks', file: 'media-files' };
|
||||
const KIND_NAMES = { artist: 'Артист', release: 'Релиз', track: 'Трек', file: 'Файл' };
|
||||
const KIND_CONFIRM = {
|
||||
@@ -355,7 +410,8 @@ async function loadTracks() {
|
||||
<td class="muted">${esc(releaseTitles[t.release_id] ?? '#' + t.release_id)}</td>
|
||||
<td class="num">${t.track_number ?? ''}</td>
|
||||
<td class="num">${fmtDuration(t.duration_seconds)}</td>
|
||||
${rowButtons('track', t.id)}
|
||||
${rowButtons('track', t.id,
|
||||
`<button class="btn secondary small play-track" data-id="${t.id}" data-title="${escAttr(t.title)}">▶</button>`)}
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
@@ -511,6 +567,9 @@ async function fedSearch() {
|
||||
<td class="num">${r.year ?? ''}</td>
|
||||
<td class="muted">${esc(fedDetails(r))}</td>
|
||||
<td class="muted" title="${esc(r.owner)}">${r.own ? 'вы' : esc(shortId(r.owner))}</td>
|
||||
<td class="actions">${r.kind === 'track'
|
||||
? `<button class="btn secondary small play-fed" data-owner="${escAttr(r.owner)}" data-item="${escAttr(r.id)}" data-own="${r.own ? 1 : 0}" data-title="${escAttr(r.name)}">▶</button>`
|
||||
: ''}</td>
|
||||
</tr>`);
|
||||
meta.textContent = `узлов опрошено: ${data.queried_nodes}, ${data.duration_ms} мс`;
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user