added player

This commit is contained in:
Ultradesu
2026-07-16 18:45:39 +03:00
parent d77a985708
commit 50320c05c6
9 changed files with 645 additions and 13 deletions
+2 -1
View File
@@ -2,4 +2,5 @@
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
*federation/
*.federation/
.cargo/config.toml
Generated
+1
View File
@@ -996,6 +996,7 @@ dependencies = [
"serde_json",
"sqlx",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
]
+4 -3
View File
@@ -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"] }
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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(&params.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, &params.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>> {
+7
View File
@@ -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")
+12
View File
@@ -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
View File
@@ -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('"', '&quot;');
}
// ---------- 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) {