Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1370c6a28 | ||
|
|
2fc5fd7960 | ||
|
|
63506e3af2 | ||
|
|
5b339aa921 | ||
|
|
42c772f735 | ||
|
|
e738086573 | ||
|
|
4b7756c36e | ||
|
|
4381750c6e | ||
|
|
3485f643f4 | ||
|
|
bca0f5e2f0 | ||
|
|
53b2ff29f8 | ||
|
|
c349512fb0 |
@@ -2,3 +2,4 @@
|
|||||||
/nul
|
/nul
|
||||||
/.claude
|
/.claude
|
||||||
/media
|
/media
|
||||||
|
/federation
|
||||||
|
|||||||
Generated
+223
-253
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.6.0-fd"
|
version = "0.7.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
|||||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
blake3 = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -31,6 +32,7 @@ anyhow = "1.0"
|
|||||||
tokio-cron-scheduler = "0.15"
|
tokio-cron-scheduler = "0.15"
|
||||||
croner = "3"
|
croner = "3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
postcard = { version = "1", features = ["alloc"] }
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||||
# P2P federation: publishes the library into a shared DHT and serves audio /
|
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
f48e5b2499a4ce1be021b2132401de29a1c23e84163d22e778874a8950757ff1
|
|
||||||
Binary file not shown.
+232
-34
@@ -13,20 +13,24 @@
|
|||||||
//! starts, stops or re-joins the node without a server restart.
|
//! starts, stops or re-joins the node without a server restart.
|
||||||
|
|
||||||
mod serve;
|
mod serve;
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use music_dht::{
|
use music_dht::{
|
||||||
ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, RendezvousConfig,
|
ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats,
|
||||||
|
RendezvousConfig, SyncStats,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use sqlx::Row as _;
|
use sqlx::Row as _;
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
use storage::PostgresFederationStorage;
|
||||||
|
|
||||||
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
||||||
|
|
||||||
@@ -39,10 +43,19 @@ struct Running {
|
|||||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ContentHashJob {
|
||||||
|
media_file_id: i64,
|
||||||
|
sha256_hash: String,
|
||||||
|
file_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Federation {
|
pub struct Federation {
|
||||||
/// Directory for the peer identity and DHT replica state.
|
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
database_url: std::sync::Mutex<String>,
|
database_url: std::sync::Mutex<String>,
|
||||||
|
storage_dir: std::sync::Mutex<String>,
|
||||||
|
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
|
||||||
|
content_pending: std::sync::Mutex<HashSet<i64>>,
|
||||||
pool: tokio::sync::OnceCell<PgPool>,
|
pool: tokio::sync::OnceCell<PgPool>,
|
||||||
running: tokio::sync::Mutex<Option<Running>>,
|
running: tokio::sync::Mutex<Option<Running>>,
|
||||||
last_sync: std::sync::Mutex<Option<String>>,
|
last_sync: std::sync::Mutex<Option<String>>,
|
||||||
@@ -66,6 +79,9 @@ pub fn handle() -> Arc<Federation> {
|
|||||||
Arc::new(Federation {
|
Arc::new(Federation {
|
||||||
data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")),
|
data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")),
|
||||||
database_url: std::sync::Mutex::new(String::new()),
|
database_url: std::sync::Mutex::new(String::new()),
|
||||||
|
storage_dir: std::sync::Mutex::new(String::new()),
|
||||||
|
content_cache: std::sync::Mutex::new(Default::default()),
|
||||||
|
content_pending: std::sync::Mutex::new(Default::default()),
|
||||||
pool: tokio::sync::OnceCell::new(),
|
pool: tokio::sync::OnceCell::new(),
|
||||||
running: tokio::sync::Mutex::new(None),
|
running: tokio::sync::Mutex::new(None),
|
||||||
last_sync: std::sync::Mutex::new(None),
|
last_sync: std::sync::Mutex::new(None),
|
||||||
@@ -134,8 +150,7 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
"federation_network_id" => effective.federation_network_id = value,
|
"federation_network_id" => effective.federation_network_id = value,
|
||||||
"agent_storage_dir" => {
|
"agent_storage_dir" => {
|
||||||
effective.agent_storage_dir =
|
effective.agent_storage_dir = crate::media_paths::resolve_config_path(&value);
|
||||||
crate::media_paths::resolve_config_path(&value);
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -147,6 +162,7 @@ impl Federation {
|
|||||||
/// node. Called at boot and every time the admin settings are saved.
|
/// node. Called at boot and every time the admin settings are saved.
|
||||||
pub async fn apply(self: &Arc<Self>, config: &AppConfig) {
|
pub async fn apply(self: &Arc<Self>, config: &AppConfig) {
|
||||||
*lock(&self.database_url) = config.database_url.clone();
|
*lock(&self.database_url) = config.database_url.clone();
|
||||||
|
*lock(&self.storage_dir) = config.agent_storage_dir.clone();
|
||||||
let network = config.federation_network_id.trim().to_string();
|
let network = config.federation_network_id.trim().to_string();
|
||||||
if config.federation_enabled && !network.is_empty() {
|
if config.federation_enabled && !network.is_empty() {
|
||||||
if let Err(err) = self.start(network, config.agent_storage_dir.clone()).await {
|
if let Err(err) = self.start(network, config.agent_storage_dir.clone()).await {
|
||||||
@@ -170,6 +186,9 @@ impl Federation {
|
|||||||
stop_running(guard.take()).await;
|
stop_running(guard.take()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||||
|
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
||||||
|
|
||||||
let config = MusicDhtConfig::builder()
|
let config = MusicDhtConfig::builder()
|
||||||
.data_dir(&self.data_dir)
|
.data_dir(&self.data_dir)
|
||||||
.network_id(NetworkId::from_name(&network_name))
|
.network_id(NetworkId::from_name(&network_name))
|
||||||
@@ -179,9 +198,10 @@ impl Federation {
|
|||||||
.stream_protocol(CATALOG_ALPN)
|
.stream_protocol(CATALOG_ALPN)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||||
let (service, mut events) = MusicDhtService::start(config)
|
let (service, mut events) =
|
||||||
.await
|
MusicDhtService::start_with_storage_and_secret_key(config, dht_storage, secret_key)
|
||||||
.map_err(|err| anyhow::anyhow!("failed to start the DHT node: {err}"))?;
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("failed to start the DHT node: {err}"))?;
|
||||||
let service = Arc::new(service);
|
let service = Arc::new(service);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
endpoint_id = %service.endpoint_id(),
|
endpoint_id = %service.endpoint_id(),
|
||||||
@@ -202,7 +222,7 @@ impl Federation {
|
|||||||
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
let mut interval = tokio::time::interval(SYNC_INTERVAL);
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
sync_self.sync_once(&sync_service).await;
|
let _ = sync_self.sync_once(&sync_service).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Serve audio and catalog requests from other peers.
|
// Serve audio and catalog requests from other peers.
|
||||||
@@ -254,54 +274,94 @@ impl Federation {
|
|||||||
async fn spawn_sync_soon(self: &Arc<Self>) {
|
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||||
if let Ok(service) = self.service().await {
|
if let Ok(service) = self.service().await {
|
||||||
let fed = Arc::clone(self);
|
let fed = Arc::clone(self);
|
||||||
tokio::spawn(async move { fed.sync_once(&service).await });
|
tokio::spawn(async move {
|
||||||
|
let _ = fed.sync_once(&service).await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
|
pub async fn sync_now(self: &Arc<Self>) -> Result<()> {
|
||||||
let service = self.service().await?;
|
let service = self.service().await?;
|
||||||
self.sync_once(&service).await;
|
let sync_stats = self.sync_once(&service).await?;
|
||||||
|
let publish_stats = match service.republish().await {
|
||||||
|
Ok(stats) => stats,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("federation republish failed: {err}");
|
||||||
|
self.set_error(Some(format!("republish failed: {err}")));
|
||||||
|
anyhow::bail!("republish failed: {err}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.record_publish_success(sync_stats, publish_stats);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_once(&self, service: &MusicDhtService) {
|
async fn sync_once(self: &Arc<Self>, service: &MusicDhtService) -> Result<SyncStats> {
|
||||||
let specs = match self.collect_specs().await {
|
let specs = match self.collect_specs().await {
|
||||||
Ok(specs) => specs,
|
Ok(specs) => specs,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!("federation sync: library read failed: {err:#}");
|
tracing::warn!("federation sync: library read failed: {err:#}");
|
||||||
self.set_error(Some(format!("library read failed: {err}")));
|
self.set_error(Some(format!("library read failed: {err}")));
|
||||||
return;
|
anyhow::bail!("library read failed: {err}");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match service.sync_library(specs).await {
|
match service.sync_library(specs).await {
|
||||||
Ok(stats) => {
|
Ok(stats) => {
|
||||||
*lock(&self.last_sync) = Some(format!(
|
self.record_sync_success(stats);
|
||||||
"{} (+{} ~{} −{}, unchanged {})",
|
if stats.failed > 0 {
|
||||||
now_iso(),
|
self.set_error(Some(format!(
|
||||||
stats.added,
|
"{} item(s) failed to publish in the last sync",
|
||||||
stats.updated,
|
stats.failed
|
||||||
stats.removed,
|
)));
|
||||||
stats.unchanged
|
} else {
|
||||||
));
|
self.set_error(None);
|
||||||
self.set_error(None);
|
}
|
||||||
|
Ok(stats)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!("federation sync failed: {err}");
|
tracing::warn!("federation sync failed: {err}");
|
||||||
self.set_error(Some(format!("sync failed: {err}")));
|
self.set_error(Some(format!("sync failed: {err}")));
|
||||||
|
Err(anyhow::anyhow!("sync failed: {err}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_sync_success(&self, stats: SyncStats) {
|
||||||
|
*lock(&self.last_sync) = Some(format!(
|
||||||
|
"{} (+{} ~{} −{}, unchanged {}, failed {})",
|
||||||
|
now_iso(),
|
||||||
|
stats.added,
|
||||||
|
stats.updated,
|
||||||
|
stats.removed,
|
||||||
|
stats.unchanged,
|
||||||
|
stats.failed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_publish_success(&self, sync_stats: SyncStats, publish_stats: PublishStats) {
|
||||||
|
*lock(&self.last_sync) = Some(format!(
|
||||||
|
"{} (+{} ~{} −{}, unchanged {}, failed {}; republished {} records, {} keys, remote nodes {})",
|
||||||
|
now_iso(),
|
||||||
|
sync_stats.added,
|
||||||
|
sync_stats.updated,
|
||||||
|
sync_stats.removed,
|
||||||
|
sync_stats.unchanged,
|
||||||
|
sync_stats.failed,
|
||||||
|
publish_stats.records,
|
||||||
|
publish_stats.keys,
|
||||||
|
publish_stats.remote_nodes,
|
||||||
|
));
|
||||||
|
self.set_error(None);
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the regular player shows, as DHT item specs: non-hidden
|
/// Everything the regular player shows, as DHT item specs: non-hidden
|
||||||
/// artists, releases and tracks (a track also hides with its release).
|
/// artists, releases and tracks (a track also hides with its release).
|
||||||
async fn collect_specs(&self) -> Result<Vec<ItemSpec>> {
|
async fn collect_specs(self: &Arc<Self>) -> Result<Vec<ItemSpec>> {
|
||||||
let pool = self.pool().await?;
|
let pool = self.pool().await?;
|
||||||
let mut specs = Vec::new();
|
let mut specs = Vec::new();
|
||||||
|
|
||||||
let artists =
|
let artists = sqlx::query("SELECT id, name FROM furumusic__artist WHERE is_hidden = false")
|
||||||
sqlx::query("SELECT id, name FROM furumusic__artist WHERE is_hidden = false")
|
.fetch_all(&pool)
|
||||||
.fetch_all(&pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
for row in &artists {
|
for row in &artists {
|
||||||
let id: i64 = row.get(0);
|
let id: i64 = row.get(0);
|
||||||
specs.push(ItemSpec {
|
specs.push(ItemSpec {
|
||||||
@@ -309,9 +369,14 @@ impl Federation {
|
|||||||
kind: ItemKind::Artist,
|
kind: ItemKind::Artist,
|
||||||
name: row.get(1),
|
name: row.get(1),
|
||||||
artist_names: Vec::new(),
|
artist_names: Vec::new(),
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
year: None,
|
year: None,
|
||||||
release_type: None,
|
release_type: None,
|
||||||
|
release_title: None,
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
duration_seconds: None,
|
duration_seconds: None,
|
||||||
|
content_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,52 +408,150 @@ impl Federation {
|
|||||||
kind: ItemKind::Release,
|
kind: ItemKind::Release,
|
||||||
name: row.get(1),
|
name: row.get(1),
|
||||||
artist_names: artists_of_release.remove(&id).unwrap_or_default(),
|
artist_names: artists_of_release.remove(&id).unwrap_or_default(),
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
year: row.get(2),
|
year: row.get(2),
|
||||||
release_type: row.get(3),
|
release_type: row.get(3),
|
||||||
|
release_title: None,
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
duration_seconds: None,
|
duration_seconds: None,
|
||||||
|
content_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let track_artists = sqlx::query(
|
let track_artists = sqlx::query(
|
||||||
"SELECT ta.track_id, a.name FROM furumusic__track_artist ta
|
"SELECT ta.track_id, a.name, ta.role FROM furumusic__track_artist ta
|
||||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
WHERE ta.role IN ('main', 'featuring')
|
WHERE ta.role IN ('main', 'featuring')
|
||||||
ORDER BY ta.track_id, ta.position",
|
ORDER BY ta.track_id,
|
||||||
|
CASE ta.role WHEN 'main' THEN 0 ELSE 1 END,
|
||||||
|
ta.position",
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
let mut artists_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
let mut artists_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
|
let mut featured_of_track: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
for row in &track_artists {
|
for row in &track_artists {
|
||||||
artists_of_track
|
let id: i64 = row.get(0);
|
||||||
.entry(row.get(0))
|
let name: String = row.get(1);
|
||||||
.or_default()
|
if row.get::<String, _>(2) == "featuring" {
|
||||||
.push(row.get(1));
|
featured_of_track.entry(id).or_default().push(name);
|
||||||
|
} else {
|
||||||
|
artists_of_track.entry(id).or_default().push(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let tracks = sqlx::query(
|
let tracks = sqlx::query(
|
||||||
"SELECT t.id, t.title, COALESCE(t.year, r.year), t.duration_seconds
|
"SELECT t.id, t.title, COALESCE(t.year, r.year), t.duration_seconds,
|
||||||
|
r.title, r.release_type, t.track_number, t.disc_number,
|
||||||
|
t.audio_file_id, m.file_path, m.sha256_hash, c.content_id
|
||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
LEFT JOIN furumusic__federation_content_id_cache c
|
||||||
|
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||||
WHERE t.is_hidden = false AND r.is_hidden = false",
|
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
let storage_dir = lock(&self.storage_dir).clone();
|
||||||
|
let mut content_hash_jobs = Vec::new();
|
||||||
for row in &tracks {
|
for row in &tracks {
|
||||||
let id: i64 = row.get(0);
|
let id: i64 = row.get(0);
|
||||||
let duration: f64 = row.get(3);
|
let duration: f64 = row.get(3);
|
||||||
|
let media_file_id: i64 = row.get(8);
|
||||||
|
let file_path: String = row.get(9);
|
||||||
|
let sha256_hash: String = row.get(10);
|
||||||
|
let cached_content_id: Option<String> = row.get(11);
|
||||||
|
let content_id = cached_content_id
|
||||||
|
.or_else(|| self.cached_content_id_for_media(media_file_id, &sha256_hash));
|
||||||
|
if content_id.is_none()
|
||||||
|
&& !storage_dir.trim().is_empty()
|
||||||
|
&& self.mark_content_hash_pending(media_file_id)
|
||||||
|
{
|
||||||
|
content_hash_jobs.push(ContentHashJob {
|
||||||
|
media_file_id,
|
||||||
|
sha256_hash,
|
||||||
|
file_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
specs.push(ItemSpec {
|
specs.push(ItemSpec {
|
||||||
local_key: format!("track:{id}"),
|
local_key: format!("track:{id}"),
|
||||||
kind: ItemKind::Track,
|
kind: ItemKind::Track,
|
||||||
name: row.get(1),
|
name: row.get(1),
|
||||||
artist_names: artists_of_track.remove(&id).unwrap_or_default(),
|
artist_names: artists_of_track.remove(&id).unwrap_or_default(),
|
||||||
|
featured_artist_names: featured_of_track.remove(&id).unwrap_or_default(),
|
||||||
year: row.get(2),
|
year: row.get(2),
|
||||||
release_type: None,
|
release_type: row.get(5),
|
||||||
|
release_title: Some(row.get(4)),
|
||||||
|
track_number: row.get(6),
|
||||||
|
disc_number: row.get(7),
|
||||||
duration_seconds: (duration > 0.0).then_some(duration),
|
duration_seconds: (duration > 0.0).then_some(duration),
|
||||||
|
content_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
self.spawn_content_warmer(pool.clone(), storage_dir, content_hash_jobs);
|
||||||
|
|
||||||
Ok(specs)
|
Ok(specs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cached_content_id_for_media(&self, media_file_id: i64, sha256_hash: &str) -> Option<String> {
|
||||||
|
if let Some((cached_hash, content_id)) = lock(&self.content_cache).get(&media_file_id)
|
||||||
|
&& cached_hash == sha256_hash
|
||||||
|
{
|
||||||
|
return Some(content_id.clone());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_content_hash_pending(&self, media_file_id: i64) -> bool {
|
||||||
|
lock(&self.content_pending).insert(media_file_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_content_warmer(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
pool: PgPool,
|
||||||
|
storage_dir: String,
|
||||||
|
jobs: Vec<ContentHashJob>,
|
||||||
|
) {
|
||||||
|
if jobs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fed = Arc::clone(self);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let total = jobs.len();
|
||||||
|
let mut stored = 0usize;
|
||||||
|
for job in jobs {
|
||||||
|
let job_storage_dir = storage_dir.clone();
|
||||||
|
let job_file_path = job.file_path.clone();
|
||||||
|
let content_id = tokio::task::spawn_blocking(move || {
|
||||||
|
audio_content_id(&job_storage_dir, &job_file_path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
lock(&fed.content_pending).remove(&job.media_file_id);
|
||||||
|
if let Some(content_id) = content_id {
|
||||||
|
lock(&fed.content_cache).insert(
|
||||||
|
job.media_file_id,
|
||||||
|
(job.sha256_hash.clone(), content_id.clone()),
|
||||||
|
);
|
||||||
|
if let Err(err) =
|
||||||
|
persist_content_id(&pool, job.media_file_id, &job.sha256_hash, &content_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
media_file_id = job.media_file_id,
|
||||||
|
"federation content-id cache write failed: {err:#}"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stored += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(total, stored, "federation content-id cache warm finished");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Live status for the admin page.
|
/// Live status for the admin page.
|
||||||
pub async fn status(&self) -> Value {
|
pub async fn status(&self) -> Value {
|
||||||
let guard = self.running.lock().await;
|
let guard = self.running.lock().await;
|
||||||
@@ -446,6 +609,41 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn persist_content_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
media_file_id: i64,
|
||||||
|
sha256_hash: &str,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_content_id_cache
|
||||||
|
(media_file_id, sha256_hash, content_id, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (media_file_id) DO UPDATE SET
|
||||||
|
sha256_hash = EXCLUDED.sha256_hash,
|
||||||
|
content_id = EXCLUDED.content_id,
|
||||||
|
updated_at = EXCLUDED.updated_at",
|
||||||
|
)
|
||||||
|
.bind(media_file_id)
|
||||||
|
.bind(sha256_hash)
|
||||||
|
.bind(content_id)
|
||||||
|
.bind(now_iso())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn audio_content_id(storage_dir: &str, file_path: &str) -> Option<String> {
|
||||||
|
if storage_dir.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
|
||||||
|
let mut file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
std::io::copy(&mut file, &mut hasher).ok()?;
|
||||||
|
Some(format!("b3:{}", hasher.finalize().to_hex()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn stop_running(running: Option<Running>) {
|
async fn stop_running(running: Option<Running>) {
|
||||||
let Some(running) = running else { return };
|
let Some(running) = running else { return };
|
||||||
for task in &running.tasks {
|
for task in &running.tasks {
|
||||||
|
|||||||
+55
-26
@@ -101,6 +101,8 @@ struct CatalogTrack {
|
|||||||
track_number: Option<i32>,
|
track_number: Option<i32>,
|
||||||
disc_number: Option<i32>,
|
disc_number: Option<i32>,
|
||||||
duration_seconds: Option<f64>,
|
duration_seconds: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
content_id: Option<String>,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +152,10 @@ async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_line<W: AsyncWriteExt + Unpin>(writer: &mut W, value: &impl Serialize) -> Result<()> {
|
async fn write_line<W: AsyncWriteExt + Unpin>(
|
||||||
|
writer: &mut W,
|
||||||
|
value: &impl Serialize,
|
||||||
|
) -> Result<()> {
|
||||||
let mut line = serde_json::to_vec(value)?;
|
let mut line = serde_json::to_vec(value)?;
|
||||||
line.push(b'\n');
|
line.push(b'\n');
|
||||||
writer.write_all(&line).await?;
|
writer.write_all(&line).await?;
|
||||||
@@ -186,7 +191,10 @@ fn guess_mime(path: &Path) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reads an image media file from disk, bounded by [`MAX_IMAGE_BYTES`].
|
/// Reads an image media file from disk, bounded by [`MAX_IMAGE_BYTES`].
|
||||||
async fn read_image(storage_dir: &str, media: Option<(String, String)>) -> Option<(Vec<u8>, String)> {
|
async fn read_image(
|
||||||
|
storage_dir: &str,
|
||||||
|
media: Option<(String, String)>,
|
||||||
|
) -> Option<(Vec<u8>, String)> {
|
||||||
let (file_path, mime) = media?;
|
let (file_path, mime) = media?;
|
||||||
let path = resolve_media_path(storage_dir, &file_path);
|
let path = resolve_media_path(storage_dir, &file_path);
|
||||||
let size = tokio::fs::metadata(&path).await.ok()?.len();
|
let size = tokio::fs::metadata(&path).await.ok()?.len();
|
||||||
@@ -367,7 +375,9 @@ async fn serve_audio_one(
|
|||||||
Some(item_id) => match resolve_track_id(&pool, &own, item_id).await {
|
Some(item_id) => match resolve_track_id(&pool, &own, item_id).await {
|
||||||
Ok(Some(track_id)) => track_id,
|
Ok(Some(track_id)) => track_id,
|
||||||
Ok(None) => return refuse_audio(stream, "track not found in the library").await,
|
Ok(None) => return refuse_audio(stream, "track not found in the library").await,
|
||||||
Err(err) => return refuse_audio(stream, &format!("library lookup failed: {err:#}")).await,
|
Err(err) => {
|
||||||
|
return refuse_audio(stream, &format!("library lookup failed: {err:#}")).await;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None => return refuse_audio(stream, "malformed item_id").await,
|
None => return refuse_audio(stream, "malformed item_id").await,
|
||||||
};
|
};
|
||||||
@@ -378,7 +388,9 @@ async fn serve_audio_one(
|
|||||||
let path = resolve_media_path(&storage_dir, &file_path);
|
let path = resolve_media_path(&storage_dir, &file_path);
|
||||||
let mut file = match tokio::fs::File::open(&path).await {
|
let mut file = match tokio::fs::File::open(&path).await {
|
||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(err) => return refuse_audio(stream, &format!("audio file is not readable: {err}")).await,
|
Err(err) => {
|
||||||
|
return refuse_audio(stream, &format!("audio file is not readable: {err}")).await;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let total_size = file.metadata().await?.len();
|
let total_size = file.metadata().await?.len();
|
||||||
let offset = request.offset.min(total_size);
|
let offset = request.offset.min(total_size);
|
||||||
@@ -395,10 +407,17 @@ async fn serve_audio_one(
|
|||||||
};
|
};
|
||||||
let (cover, artist_image) = if request.want_cover {
|
let (cover, artist_image) = if request.want_cover {
|
||||||
(
|
(
|
||||||
read_image(&storage_dir, track_cover_file(&pool, track_id).await.ok().flatten()).await,
|
|
||||||
read_image(
|
read_image(
|
||||||
&storage_dir,
|
&storage_dir,
|
||||||
track_artist_image_file(&pool, track_id).await.ok().flatten(),
|
track_cover_file(&pool, track_id).await.ok().flatten(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
read_image(
|
||||||
|
&storage_dir,
|
||||||
|
track_artist_image_file(&pool, track_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
@@ -511,7 +530,10 @@ async fn serve_catalog_one(
|
|||||||
artist: None,
|
artist: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
stream.send.write_all(&serde_json::to_vec(&response)?).await?;
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Some(want @ ("artist_image" | "release_cover")) => {
|
Some(want @ ("artist_image" | "release_cover")) => {
|
||||||
let media = if want == "release_cover" {
|
let media = if want == "release_cover" {
|
||||||
@@ -549,7 +571,10 @@ async fn serve_catalog_one(
|
|||||||
error: Some(format!("unknown request kind '{other}'")),
|
error: Some(format!("unknown request kind '{other}'")),
|
||||||
artist: None,
|
artist: None,
|
||||||
};
|
};
|
||||||
stream.send.write_all(&serde_json::to_vec(&response)?).await?;
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stream.send.finish()?;
|
stream.send.finish()?;
|
||||||
@@ -589,32 +614,36 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
|
|||||||
for release_row in release_rows {
|
for release_row in release_rows {
|
||||||
let release_id: i64 = release_row.get(0);
|
let release_id: i64 = release_row.get(0);
|
||||||
let track_rows = sqlx::query(
|
let track_rows = sqlx::query(
|
||||||
"SELECT id, title, track_number, disc_number, duration_seconds
|
"SELECT t.id, t.title, t.track_number, t.disc_number, t.duration_seconds,
|
||||||
FROM furumusic__track
|
c.content_id
|
||||||
WHERE release_id = $1 AND is_hidden = false
|
FROM furumusic__track t
|
||||||
ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title",
|
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||||
|
LEFT JOIN furumusic__federation_content_id_cache c
|
||||||
|
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||||
|
WHERE t.release_id = $1 AND t.is_hidden = false
|
||||||
|
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title",
|
||||||
)
|
)
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
let mut tracks = Vec::with_capacity(track_rows.len());
|
||||||
|
for row in track_rows {
|
||||||
|
let track_id: i64 = row.get(0);
|
||||||
|
let duration: f64 = row.get(4);
|
||||||
|
tracks.push(CatalogTrack {
|
||||||
|
title: row.get(1),
|
||||||
|
track_number: row.get(2),
|
||||||
|
disc_number: row.get(3),
|
||||||
|
duration_seconds: (duration > 0.0).then_some(duration),
|
||||||
|
content_id: row.get(5),
|
||||||
|
item_id: item_id_of(own, track_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
releases.push(CatalogRelease {
|
releases.push(CatalogRelease {
|
||||||
title: release_row.get(1),
|
title: release_row.get(1),
|
||||||
release_type: release_row.get(2),
|
release_type: release_row.get(2),
|
||||||
year: release_row.get(3),
|
year: release_row.get(3),
|
||||||
tracks: track_rows
|
tracks,
|
||||||
.into_iter()
|
|
||||||
.map(|row| {
|
|
||||||
let track_id: i64 = row.get(0);
|
|
||||||
let duration: f64 = row.get(4);
|
|
||||||
CatalogTrack {
|
|
||||||
title: row.get(1),
|
|
||||||
track_number: row.get(2),
|
|
||||||
disc_number: row.get(3),
|
|
||||||
duration_seconds: (duration > 0.0).then_some(duration),
|
|
||||||
item_id: item_id_of(own, track_id),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use music_dht::{
|
||||||
|
DhtKey, EndpointId, LibraryItem, MAX_RECORDS_PER_RESPONSE, MusicDhtError, MusicDhtStorage,
|
||||||
|
NodeContact, NodeId, SecretKey, StoreDecision, StoredRecord, decide_store,
|
||||||
|
};
|
||||||
|
use sqlx::{PgPool, Row as _};
|
||||||
|
|
||||||
|
const IDENTITY_NAME: &str = "default";
|
||||||
|
|
||||||
|
const SCHEMA: &[&str] = &[
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_identity (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
secret_key BYTEA NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_local_item (
|
||||||
|
id BYTEA PRIMARY KEY,
|
||||||
|
normalized_name TEXT NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
payload BYTEA NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_local_item_normalized_name
|
||||||
|
ON furumusic__federation_local_item(normalized_name)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_dht_record (
|
||||||
|
dht_key BYTEA NOT NULL,
|
||||||
|
item_id BYTEA NOT NULL,
|
||||||
|
owner_peer_id TEXT NOT NULL,
|
||||||
|
payload BYTEA NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL,
|
||||||
|
expires_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (dht_key, item_id, owner_peer_id)
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_dht_record_expires_at
|
||||||
|
ON furumusic__federation_dht_record(expires_at_ms)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_known_peer (
|
||||||
|
peer_id TEXT PRIMARY KEY,
|
||||||
|
node_id BYTEA NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
last_seen_ms BIGINT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||||
|
media_file_id BIGINT PRIMARY KEY,
|
||||||
|
sha256_hash TEXT NOT NULL,
|
||||||
|
content_id TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||||
|
ON furumusic__federation_content_id_cache(content_id)",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PostgresFederationStorage {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresFederationStorage {
|
||||||
|
pub async fn new(pool: PgPool) -> music_dht::Result<Self> {
|
||||||
|
let storage = Self { pool };
|
||||||
|
storage.ensure_schema().await?;
|
||||||
|
Ok(storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_or_create_secret_key(&self) -> music_dht::Result<SecretKey> {
|
||||||
|
if let Some(bytes) = sqlx::query_scalar::<_, Vec<u8>>(
|
||||||
|
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
{
|
||||||
|
return secret_from_bytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = SecretKey::generate();
|
||||||
|
let key_bytes = key.to_bytes();
|
||||||
|
let now = now_iso();
|
||||||
|
let inserted = sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_identity
|
||||||
|
(name, secret_key, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (name) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.bind(key_bytes.as_slice())
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
.rows_affected();
|
||||||
|
if inserted == 1 {
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = sqlx::query_scalar::<_, Vec<u8>>(
|
||||||
|
"SELECT secret_key FROM furumusic__federation_identity WHERE name = $1",
|
||||||
|
)
|
||||||
|
.bind(IDENTITY_NAME)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
secret_from_bytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_schema(&self) -> music_dht::Result<()> {
|
||||||
|
for sql in SCHEMA {
|
||||||
|
sqlx::query(sql)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MusicDhtStorage for PostgresFederationStorage {
|
||||||
|
async fn upsert_local_item(&self, item: &LibraryItem) -> music_dht::Result<()> {
|
||||||
|
let payload = postcard::to_stdvec(item).map_err(db_error)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_local_item
|
||||||
|
(id, normalized_name, revision, deleted, updated_at_ms, payload)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
normalized_name = EXCLUDED.normalized_name,
|
||||||
|
revision = EXCLUDED.revision,
|
||||||
|
deleted = EXCLUDED.deleted,
|
||||||
|
updated_at_ms = EXCLUDED.updated_at_ms,
|
||||||
|
payload = EXCLUDED.payload",
|
||||||
|
)
|
||||||
|
.bind(item.id.as_bytes().as_slice())
|
||||||
|
.bind(&item.normalized_name)
|
||||||
|
.bind(item.revision as i64)
|
||||||
|
.bind(item.deleted)
|
||||||
|
.bind(item.updated_at_ms as i64)
|
||||||
|
.bind(payload)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_local_items(&self, include_deleted: bool) -> music_dht::Result<Vec<LibraryItem>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT payload
|
||||||
|
FROM furumusic__federation_local_item
|
||||||
|
WHERE $1 OR deleted = false
|
||||||
|
ORDER BY normalized_name",
|
||||||
|
)
|
||||||
|
.bind(include_deleted)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| postcard::from_bytes::<LibraryItem>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_dht_record(&self, key: DhtKey, record: StoredRecord) -> music_dht::Result<bool> {
|
||||||
|
let mut conn = self.pool.acquire().await.map_err(db_error)?;
|
||||||
|
store_record_in_conn(&mut conn, &key, &record).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_dht_records(
|
||||||
|
&self,
|
||||||
|
entries: Vec<(DhtKey, StoredRecord)>,
|
||||||
|
) -> music_dht::Result<Vec<bool>> {
|
||||||
|
let mut tx = self.pool.begin().await.map_err(db_error)?;
|
||||||
|
let mut stored = Vec::with_capacity(entries.len());
|
||||||
|
for (key, record) in &entries {
|
||||||
|
stored.push(store_record_in_conn(&mut tx, key, record).await?);
|
||||||
|
}
|
||||||
|
tx.commit().await.map_err(db_error)?;
|
||||||
|
Ok(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dht_records_by_key(
|
||||||
|
&self,
|
||||||
|
key: DhtKey,
|
||||||
|
now_ms: u64,
|
||||||
|
) -> music_dht::Result<Vec<StoredRecord>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT payload
|
||||||
|
FROM furumusic__federation_dht_record
|
||||||
|
WHERE dht_key = $1 AND expires_at_ms > $2
|
||||||
|
ORDER BY expires_at_ms DESC, item_id
|
||||||
|
LIMIT $3",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(now_ms as i64)
|
||||||
|
.bind(MAX_RECORDS_PER_RESPONSE as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| postcard::from_bytes::<StoredRecord>(&row.get::<Vec<u8>, _>(0)).ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_expired_records(&self, now_ms: u64) -> music_dht::Result<usize> {
|
||||||
|
let result =
|
||||||
|
sqlx::query("DELETE FROM furumusic__federation_dht_record WHERE expires_at_ms <= $1")
|
||||||
|
.bind(now_ms as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(result.rows_affected() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_known_peer(&self, contact: &NodeContact) -> music_dht::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_known_peer
|
||||||
|
(peer_id, node_id, ticket, last_seen_ms)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (peer_id) DO UPDATE SET
|
||||||
|
node_id = EXCLUDED.node_id,
|
||||||
|
ticket = EXCLUDED.ticket,
|
||||||
|
last_seen_ms = EXCLUDED.last_seen_ms",
|
||||||
|
)
|
||||||
|
.bind(contact.peer_id.to_string())
|
||||||
|
.bind(contact.node_id.as_bytes().as_slice())
|
||||||
|
.bind(&contact.ticket)
|
||||||
|
.bind(contact.last_seen_ms as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_known_peer(&self, peer_id: EndpointId) -> music_dht::Result<()> {
|
||||||
|
sqlx::query("DELETE FROM furumusic__federation_known_peer WHERE peer_id = $1")
|
||||||
|
.bind(peer_id.to_string())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_known_peers(&self) -> music_dht::Result<Vec<NodeContact>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT peer_id, node_id, ticket, last_seen_ms
|
||||||
|
FROM furumusic__federation_known_peer",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
let mut contacts = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let peer_id: String = row.get(0);
|
||||||
|
let node_id: Vec<u8> = row.get(1);
|
||||||
|
let ticket: String = row.get(2);
|
||||||
|
let last_seen_ms: i64 = row.get(3);
|
||||||
|
let Ok(peer_id) = EndpointId::from_str(&peer_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(node_id) = <[u8; 32]>::try_from(node_id.as_slice()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
contacts.push(NodeContact {
|
||||||
|
node_id: NodeId::from_bytes(node_id),
|
||||||
|
peer_id,
|
||||||
|
ticket,
|
||||||
|
last_seen_ms: last_seen_ms as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(contacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secret_from_bytes(bytes: Vec<u8>) -> music_dht::Result<SecretKey> {
|
||||||
|
let bytes: [u8; 32] = bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| MusicDhtError::Database("stored federation identity is corrupted".into()))?;
|
||||||
|
Ok(SecretKey::from_bytes(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one validated record following the revision/tombstone rules.
|
||||||
|
/// Returns `true` if the record was written or refreshed. Runs against a
|
||||||
|
/// pooled connection or an open transaction.
|
||||||
|
async fn store_record_in_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
key: &DhtKey,
|
||||||
|
record: &StoredRecord,
|
||||||
|
) -> music_dht::Result<bool> {
|
||||||
|
let existing = sqlx::query(
|
||||||
|
"SELECT revision, deleted, expires_at_ms
|
||||||
|
FROM furumusic__federation_dht_record
|
||||||
|
WHERE dht_key = $1 AND item_id = $2 AND owner_peer_id = $3",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(record.item.id.as_bytes().as_slice())
|
||||||
|
.bind(record.item.owner.to_string())
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
.map(|row| {
|
||||||
|
(
|
||||||
|
row.get::<i64, _>(0) as u64,
|
||||||
|
row.get::<bool, _>(1),
|
||||||
|
row.get::<i64, _>(2) as u64,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
match decide_store(existing, record) {
|
||||||
|
StoreDecision::Ignore => return Ok(false),
|
||||||
|
StoreDecision::Write | StoreDecision::RefreshExpiry(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = postcard::to_stdvec(record).map_err(db_error)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO furumusic__federation_dht_record
|
||||||
|
(dht_key, item_id, owner_peer_id, payload, revision, deleted, expires_at_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (dht_key, item_id, owner_peer_id) DO UPDATE SET
|
||||||
|
payload = EXCLUDED.payload,
|
||||||
|
revision = EXCLUDED.revision,
|
||||||
|
deleted = EXCLUDED.deleted,
|
||||||
|
expires_at_ms = EXCLUDED.expires_at_ms",
|
||||||
|
)
|
||||||
|
.bind(key.as_bytes().as_slice())
|
||||||
|
.bind(record.item.id.as_bytes().as_slice())
|
||||||
|
.bind(record.item.owner.to_string())
|
||||||
|
.bind(payload)
|
||||||
|
.bind(record.item.revision as i64)
|
||||||
|
.bind(record.item.deleted)
|
||||||
|
.bind(record.expires_at_ms as i64)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn db_error(err: impl std::fmt::Display) -> MusicDhtError {
|
||||||
|
MusicDhtError::Database(err.to_string())
|
||||||
|
}
|
||||||
@@ -309,6 +309,7 @@ translations! {
|
|||||||
player_cancel: "Cancel" , "Отмена";
|
player_cancel: "Cancel" , "Отмена";
|
||||||
player_create: "Create" , "Создать";
|
player_create: "Create" , "Создать";
|
||||||
player_save: "Save" , "Сохранить";
|
player_save: "Save" , "Сохранить";
|
||||||
|
player_done: "Done" , "Готово";
|
||||||
player_delete: "Delete" , "Удалить";
|
player_delete: "Delete" , "Удалить";
|
||||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||||
player_rename: "Rename" , "Переименовать";
|
player_rename: "Rename" , "Переименовать";
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,7 @@ mod agent;
|
|||||||
mod api;
|
mod api;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod federation;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
mod jobs;
|
mod jobs;
|
||||||
mod lastfm;
|
mod lastfm;
|
||||||
@@ -12,7 +13,6 @@ mod music;
|
|||||||
mod oidc;
|
mod oidc;
|
||||||
mod player;
|
mod player;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod federation;
|
|
||||||
mod torrents;
|
mod torrents;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -74,6 +74,13 @@ pub(super) struct TrackItem {
|
|||||||
pub(super) lastfm_updated_at: Option<String>,
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistTrackItem {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub(super) track: TrackItem,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct ArtistAppearanceTrack {
|
pub(super) struct ArtistAppearanceTrack {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -286,7 +293,7 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) is_public: bool,
|
pub(super) is_public: bool,
|
||||||
pub(super) is_saved: bool,
|
pub(super) is_saved: bool,
|
||||||
pub(super) kind: String,
|
pub(super) kind: String,
|
||||||
pub(super) tracks: Vec<TrackItem>,
|
pub(super) tracks: Vec<PlaylistTrackItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
|||||||
+200
-12
@@ -3414,6 +3414,7 @@ async fn artist_detail_handler(
|
|||||||
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT * FROM (
|
r#"SELECT * FROM (
|
||||||
SELECT DISTINCT ON (lower(t.title::text))
|
SELECT DISTINCT ON (lower(t.title::text))
|
||||||
|
NULL::bigint AS playlist_track_id,
|
||||||
t.id, t.title::text as title, t.track_number, t.disc_number,
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
@@ -3515,7 +3516,8 @@ async fn release_detail_handler(
|
|||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3687,7 +3689,8 @@ async fn playlist_detail_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT pt.id AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3715,7 +3718,7 @@ async fn playlist_detail_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let track_items = build_track_items(tracks, pool).await?;
|
let track_items = build_playlist_track_items(tracks, pool).await?;
|
||||||
|
|
||||||
Json(PlaylistDetail {
|
Json(PlaylistDetail {
|
||||||
id: info.id,
|
id: info.id,
|
||||||
@@ -3813,13 +3816,34 @@ async fn build_track_items(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn build_playlist_track_items(
|
||||||
|
tracks: Vec<PlaylistTrackRow>,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> cot::Result<Vec<PlaylistTrackItem>> {
|
||||||
|
let playlist_track_ids = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.playlist_track_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let track_items = build_track_items(tracks, pool).await?;
|
||||||
|
|
||||||
|
Ok(track_items
|
||||||
|
.into_iter()
|
||||||
|
.zip(playlist_track_ids)
|
||||||
|
.map(|(track, playlist_track_id)| PlaylistTrackItem {
|
||||||
|
playlist_track_id,
|
||||||
|
track,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Result<Vec<TrackItem>> {
|
async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Result<Vec<TrackItem>> {
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3976,7 +4000,8 @@ async fn likes_playlist_handler(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -4004,7 +4029,14 @@ async fn likes_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let track_items = build_track_items(tracks, pool).await?;
|
let track_items = build_track_items(tracks, pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|track| PlaylistTrackItem {
|
||||||
|
playlist_track_id: None,
|
||||||
|
track,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Json(PlaylistDetail {
|
Json(PlaylistDetail {
|
||||||
id: -1,
|
id: -1,
|
||||||
@@ -5609,6 +5641,110 @@ async fn add_tracks_to_playlist_handler(
|
|||||||
Json(serde_json::json!({"ok": true})).into_response()
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /api/player/playlists/{id}/tracks — reorder playlist tracks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn reorder_playlist_tracks_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
path: Path<PathId>,
|
||||||
|
Json(body): Json<ReorderPlaylistRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
let playlist_id = path.0.id;
|
||||||
|
let owner: Option<(i64,)> =
|
||||||
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let Some(owner) = owner else {
|
||||||
|
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
||||||
|
};
|
||||||
|
if owner.0 != user.id {
|
||||||
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_ids = body
|
||||||
|
.playlist_track_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| *id > 0)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let unique_requested = requested_ids.iter().copied().collect::<HashSet<_>>();
|
||||||
|
if unique_requested.len() != requested_ids.len() {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"duplicate playlist track ids",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_ids = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT pt.id
|
||||||
|
FROM furumusic__playlist_track pt
|
||||||
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
|
WHERE pt.playlist_id = $1 AND t.is_hidden = false
|
||||||
|
ORDER BY pt.position"#,
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let visible_set = visible_ids.iter().copied().collect::<HashSet<_>>();
|
||||||
|
if visible_set != unique_requested {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"playlist track ids do not match this playlist",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hidden_ids = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT pt.id
|
||||||
|
FROM furumusic__playlist_track pt
|
||||||
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
|
WHERE pt.playlist_id = $1 AND t.is_hidden = true
|
||||||
|
ORDER BY pt.position"#,
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut ordered_ids = requested_ids;
|
||||||
|
ordered_ids.extend(hidden_ids);
|
||||||
|
if !ordered_ids.is_empty() {
|
||||||
|
sqlx::query(
|
||||||
|
r#"WITH ordered AS (
|
||||||
|
SELECT id, ord::integer - 1 AS position
|
||||||
|
FROM unnest($1::bigint[]) WITH ORDINALITY AS u(id, ord)
|
||||||
|
)
|
||||||
|
UPDATE furumusic__playlist_track pt
|
||||||
|
SET position = ordered.position
|
||||||
|
FROM ordered
|
||||||
|
WHERE pt.id = ordered.id AND pt.playlist_id = $2"#,
|
||||||
|
)
|
||||||
|
.bind(&ordered_ids)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DELETE /api/player/playlists/{id}/tracks — remove a track from playlist
|
// DELETE /api/player/playlists/{id}/tracks — remove a track from playlist
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -5638,12 +5774,29 @@ async fn remove_track_from_playlist_handler(
|
|||||||
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2")
|
match (body.playlist_track_id, body.track_id) {
|
||||||
.bind(playlist_id)
|
(Some(playlist_track_id), _) => {
|
||||||
.bind(body.track_id)
|
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND id = $2")
|
||||||
.execute(pool)
|
.bind(playlist_id)
|
||||||
.await
|
.bind(playlist_track_id)
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
(None, Some(track_id)) => {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2",
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.bind(track_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "missing track id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-number positions
|
// Re-number positions
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -5660,6 +5813,14 @@ async fn remove_track_from_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
Json(serde_json::json!({"ok": true})).into_response()
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7618,6 +7779,33 @@ impl App for PlayerApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.put({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathId>,
|
||||||
|
json: Json<ReorderPlaylistRequest>| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
async move {
|
||||||
|
let pg_pool = pool
|
||||||
|
.get_or_init(|| async {
|
||||||
|
sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(&pool_config.database_url)
|
||||||
|
.await
|
||||||
|
.expect("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
reorder_playlist_tracks_handler(
|
||||||
|
auth_ctx, session, db, pg_pool, path, json,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.delete({
|
.delete({
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ pub(super) struct AddTracksRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct RemoveTrackRequest {
|
pub(super) struct RemoveTrackRequest {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct ReorderPlaylistRequest {
|
||||||
|
pub(super) playlist_track_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
|
|||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct PlaylistTrackRow {
|
pub(super) struct PlaylistTrackRow {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
|
|||||||
@@ -2745,6 +2745,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
async openPlaylist(id, options = {}) {
|
async openPlaylist(id, options = {}) {
|
||||||
this._beginNavigation('#playlist/' + id, options);
|
this._beginNavigation('#playlist/' + id, options);
|
||||||
|
Alpine.store('playlists')?.stopEdit?.();
|
||||||
this.view = 'playlist_detail';
|
this.view = 'playlist_detail';
|
||||||
this.currentPlaylist = null;
|
this.currentPlaylist = null;
|
||||||
try {
|
try {
|
||||||
@@ -2756,6 +2757,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showSharedPlaylist(share, options = {}) {
|
showSharedPlaylist(share, options = {}) {
|
||||||
|
Alpine.store('playlists')?.stopEdit?.();
|
||||||
this._saveScrollPosition(this._activeHash);
|
this._saveScrollPosition(this._activeHash);
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.searchResults = null;
|
this.searchResults = null;
|
||||||
@@ -4301,6 +4303,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
list: [],
|
list: [],
|
||||||
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
||||||
picker: null, // { trackIds: [1,2,3] }
|
picker: null, // { trackIds: [1,2,3] }
|
||||||
|
editingPlaylistId: null,
|
||||||
|
_dragIdx: null,
|
||||||
|
_dragOverIdx: null,
|
||||||
|
_pointerDragMove: null,
|
||||||
|
_pointerDragEnd: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.reload();
|
this.reload();
|
||||||
@@ -4333,6 +4340,214 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
|
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
currentPlaylist() {
|
||||||
|
return Alpine.store('library')?.currentPlaylist || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
canEditCurrent() {
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
return !!playlist && playlist.kind === 'user' && playlist.is_own && Number(playlist.id) > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
isEditingCurrent() {
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
return this.canEditCurrent() && Number(this.editingPlaylistId) === Number(playlist.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleEditCurrent() {
|
||||||
|
if (this.isEditingCurrent()) {
|
||||||
|
this.stopEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.canEditCurrent()) return;
|
||||||
|
this.editingPlaylistId = Number(this.currentPlaylist().id);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopEdit() {
|
||||||
|
this._endPointerReorder(false);
|
||||||
|
this.endDrag();
|
||||||
|
this.editingPlaylistId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateListTrackCount(playlistId, count) {
|
||||||
|
const id = Number(playlistId);
|
||||||
|
this.list = this.list.map(pl => (
|
||||||
|
Number(pl.id) === id ? { ...pl, track_count: count } : pl
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
|
_playlistTrackIds(tracks) {
|
||||||
|
return (tracks || []).map(track => Number(track?.playlist_track_id || 0)).filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeCurrentTrack(track, idx) {
|
||||||
|
if (!this.isEditingCurrent() || !track) return;
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
const playlistId = Number(playlist.id);
|
||||||
|
const playlistTrackId = Number(track.playlist_track_id || 0);
|
||||||
|
const previous = (playlist.tracks || []).slice();
|
||||||
|
if (!playlistTrackId) return;
|
||||||
|
|
||||||
|
playlist.tracks = previous.filter(item => Number(item.playlist_track_id || 0) !== playlistTrackId);
|
||||||
|
this._updateListTrackCount(playlistId, playlist.tracks.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlist_track_id: playlistTrackId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('remove failed');
|
||||||
|
await this.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (Number(this.currentPlaylist()?.id) === playlistId) {
|
||||||
|
this.currentPlaylist().tracks = previous;
|
||||||
|
this._updateListTrackCount(playlistId, previous.length);
|
||||||
|
}
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startDrag(event, idx) {
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._dragIdx = idx;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
if (event?.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', String(tracks[idx].playlist_track_id));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
dragOver(event, idx) {
|
||||||
|
if (!this.isEditingCurrent() || this._dragIdx === null) return;
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (idx < 0 || idx >= tracks.length) return;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
if (event?.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
if (idx !== this._dragIdx) event.currentTarget.classList.add('drag-over');
|
||||||
|
},
|
||||||
|
|
||||||
|
dropOn(idx) {
|
||||||
|
const fromIdx = this._dragIdx;
|
||||||
|
this.endDrag();
|
||||||
|
if (!Number.isInteger(fromIdx) || fromIdx === idx) return;
|
||||||
|
this.moveCurrentTrack(fromIdx, idx);
|
||||||
|
},
|
||||||
|
|
||||||
|
endDrag() {
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
this._dragIdx = null;
|
||||||
|
this._dragOverIdx = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async moveCurrentTrack(fromIdx, toIdx) {
|
||||||
|
if (!this.isEditingCurrent()) return;
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
const playlistId = Number(playlist.id);
|
||||||
|
const previous = (playlist.tracks || []).slice();
|
||||||
|
if (fromIdx < 0 || fromIdx >= previous.length || toIdx < 0 || toIdx >= previous.length) return;
|
||||||
|
const next = previous.slice();
|
||||||
|
const [track] = next.splice(fromIdx, 1);
|
||||||
|
next.splice(toIdx, 0, track);
|
||||||
|
playlist.tracks = next;
|
||||||
|
|
||||||
|
const playlistTrackIds = this._playlistTrackIds(next);
|
||||||
|
if (playlistTrackIds.length !== next.length) {
|
||||||
|
playlist.tracks = previous;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlist_track_ids: playlistTrackIds }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('reorder failed');
|
||||||
|
await this.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (Number(this.currentPlaylist()?.id) === playlistId) {
|
||||||
|
this.currentPlaylist().tracks = previous;
|
||||||
|
}
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startPointerReorder(event, idx) {
|
||||||
|
if (event.pointerType === 'mouse') return;
|
||||||
|
if (event.button && event.button !== 0) return;
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this._endPointerReorder(false);
|
||||||
|
this._dragIdx = idx;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
const handle = event.currentTarget;
|
||||||
|
try {
|
||||||
|
handle?.setPointerCapture?.(event.pointerId);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
this._pointerDragMove = (moveEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
this._autoScrollDuringReorder(moveEvent.clientY);
|
||||||
|
const target = document
|
||||||
|
.elementFromPoint(moveEvent.clientX, moveEvent.clientY)
|
||||||
|
?.closest?.('.playlist-track-row[data-playlist-index]');
|
||||||
|
const targetIdx = Number(target?.dataset?.playlistIndex);
|
||||||
|
const currentTracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!Number.isInteger(targetIdx) || targetIdx < 0 || targetIdx >= currentTracks.length) return;
|
||||||
|
this._dragOverIdx = targetIdx;
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
if (targetIdx !== this._dragIdx) target.classList.add('drag-over');
|
||||||
|
};
|
||||||
|
|
||||||
|
this._pointerDragEnd = (endEvent) => {
|
||||||
|
try {
|
||||||
|
if (handle?.hasPointerCapture?.(endEvent.pointerId)) handle.releasePointerCapture(endEvent.pointerId);
|
||||||
|
} catch (_) {}
|
||||||
|
this._endPointerReorder(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', this._pointerDragMove, { passive: false });
|
||||||
|
window.addEventListener('pointerup', this._pointerDragEnd, { passive: false });
|
||||||
|
window.addEventListener('pointercancel', this._pointerDragEnd, { passive: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
_autoScrollDuringReorder(clientY) {
|
||||||
|
const scroller = document.getElementById('center-scroll');
|
||||||
|
if (!scroller) return;
|
||||||
|
const rect = scroller.getBoundingClientRect();
|
||||||
|
const edge = 52;
|
||||||
|
if (clientY < rect.top + edge) {
|
||||||
|
scroller.scrollTop -= Math.ceil((rect.top + edge - clientY) / 4);
|
||||||
|
} else if (clientY > rect.bottom - edge) {
|
||||||
|
scroller.scrollTop += Math.ceil((clientY - (rect.bottom - edge)) / 4);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_endPointerReorder(commit) {
|
||||||
|
if (this._pointerDragMove) window.removeEventListener('pointermove', this._pointerDragMove);
|
||||||
|
if (this._pointerDragEnd) {
|
||||||
|
window.removeEventListener('pointerup', this._pointerDragEnd);
|
||||||
|
window.removeEventListener('pointercancel', this._pointerDragEnd);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
const fromIdx = this._dragIdx;
|
||||||
|
const toIdx = this._dragOverIdx;
|
||||||
|
this._pointerDragMove = null;
|
||||||
|
this._pointerDragEnd = null;
|
||||||
|
this._dragIdx = null;
|
||||||
|
this._dragOverIdx = null;
|
||||||
|
if (commit && Number.isInteger(fromIdx) && Number.isInteger(toIdx) && fromIdx !== toIdx) {
|
||||||
|
this.moveCurrentTrack(fromIdx, toIdx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
showCreate() {
|
showCreate() {
|
||||||
this.modal = { mode: 'create', title: '' };
|
this.modal = { mode: 'create', title: '' };
|
||||||
},
|
},
|
||||||
|
|||||||
+65
-15
@@ -951,7 +951,23 @@
|
|||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
<div class="playlist-detail-heading">
|
||||||
|
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
||||||
|
<button class="release-action-btn secondary playlist-edit-toggle"
|
||||||
|
x-show="$store.playlists.canEditCurrent()"
|
||||||
|
x-cloak
|
||||||
|
@click="$store.playlists.toggleEditCurrent()"
|
||||||
|
:title="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'">
|
||||||
|
<svg x-show="!$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||||||
|
<path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
</svg>
|
||||||
|
<svg x-show="$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 6L9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
<span x-text="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="playlist-detail-meta"
|
<div class="playlist-detail-meta"
|
||||||
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
||||||
<span x-show="$store.library.currentPlaylist.owner_name"
|
<span x-show="$store.library.currentPlaylist.owner_name"
|
||||||
@@ -963,27 +979,61 @@
|
|||||||
<template x-if="$store.library.currentPlaylist.description">
|
<template x-if="$store.library.currentPlaylist.description">
|
||||||
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
||||||
</template>
|
</template>
|
||||||
<div class="track-list-header">
|
<div class="track-list-header playlist-track-list-header"
|
||||||
|
:class="{ editing: $store.playlists.isEditingCurrent() }">
|
||||||
|
<span class="playlist-edit-cell"
|
||||||
|
x-show="$store.playlists.isEditingCurrent()"
|
||||||
|
x-cloak></span>
|
||||||
<span>#</span>
|
<span>#</span>
|
||||||
<span>{{ t.player_title }}</span>
|
<span>{{ t.player_title }}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
|
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.playlist_track_id || (track.id + '-' + idx)">
|
||||||
<div class="track-row"
|
<div class="track-row playlist-track-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:data-playlist-index="idx"
|
||||||
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, editing: $store.playlists.isEditingCurrent(), dragging: $store.playlists._dragIdx === idx }"
|
||||||
|
:draggable="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||||
|
@dblclick="if (!$store.playlists.isEditingCurrent()) $store.queue.playRelease($store.library.currentPlaylist.tracks, idx)"
|
||||||
|
@dragstart="if (!$store.playlists.startDrag($event, idx)) $event.preventDefault()"
|
||||||
|
@dragend="$store.playlists.endDrag()"
|
||||||
|
@dragover.prevent="$store.playlists.dragOver($event, idx)"
|
||||||
|
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
||||||
|
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); $store.playlists.dropOn(idx)">
|
||||||
|
<button class="playlist-track-remove"
|
||||||
|
x-show="$store.playlists.isEditingCurrent()"
|
||||||
|
x-cloak
|
||||||
|
@click.stop="$store.playlists.removeCurrentTrack(track, idx)"
|
||||||
|
title="{{ t.player_remove }}"
|
||||||
|
aria-label="{{ t.player_remove }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<span class="track-num" x-text="idx + 1"></span>
|
<span class="track-num" x-text="idx + 1"></span>
|
||||||
<div class="track-info">
|
<div class="track-info playlist-track-info">
|
||||||
<div class="track-title" x-text="track.title"></div>
|
<button class="playlist-drag-handle"
|
||||||
<div class="track-artists-inline">
|
x-show="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
x-cloak
|
||||||
<span>
|
@mousedown.stop
|
||||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
@click.stop
|
||||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
@pointerdown.stop="$store.playlists.startPointerReorder($event, idx)"
|
||||||
</span>
|
title="{{ t.player_edit }}"
|
||||||
</template>
|
aria-label="{{ t.player_edit }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="playlist-track-copy">
|
||||||
|
<div class="track-title" x-text="track.title"></div>
|
||||||
|
<div class="track-artists-inline">
|
||||||
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||||
|
<span>
|
||||||
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||||
|
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
|
|||||||
@@ -489,6 +489,25 @@ button.user-stat:hover {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading .section-title {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -735,6 +754,93 @@ button.user-stat:hover {
|
|||||||
.track-row:hover { background: var(--bg-hover); }
|
.track-row:hover { background: var(--bg-hover); }
|
||||||
.track-row.playing { color: var(--accent); }
|
.track-row.playing { color: var(--accent); }
|
||||||
.track-row.playing .track-num { color: var(--accent); }
|
.track-row.playing .track-num { color: var(--accent); }
|
||||||
|
.playlist-track-list-header.editing,
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 32px 40px minmax(0, 1fr) minmax(0, 1fr) 154px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.drag-over {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-copy {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle {
|
||||||
|
width: 24px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
padding: 0;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row.shared-target {
|
.track-row.shared-target {
|
||||||
background: rgba(29, 185, 84, 0.12);
|
background: rgba(29, 185, 84, 0.12);
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
@@ -4233,6 +4339,16 @@ button.user-stat:hover {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-track-list-header.editing,
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 30px 32px minmax(0, 1fr) auto 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-list-header.editing span:nth-child(4),
|
||||||
|
.playlist-track-row.editing > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.history-table-head,
|
.history-table-head,
|
||||||
.history-row.track-row {
|
.history-row.track-row {
|
||||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||||
@@ -5158,6 +5274,26 @@ button.user-stat:hover {
|
|||||||
padding: 10px 6px;
|
padding: 10px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-toggle {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing .track-num,
|
||||||
|
.playlist-track-row.editing > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row > span:nth-child(3),
|
.track-row > span:nth-child(3),
|
||||||
.track-duration {
|
.track-duration {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user