Compare commits

..
6 Commits
Author SHA1 Message Date
Ultradesu 5b339aa921 Fixed content_id persistence
Build and Publish / Build and Publish Docker Image (push) Successful in 7m30s
2026-07-20 18:40:32 +03:00
Ultradesu 42c772f735 Fixed blake3 computation
Build and Publish / Build and Publish Docker Image (push) Successful in 5m5s
2026-07-20 18:24:39 +03:00
Ultradesu e738086573 Extend DHT scheme with content_id
Build and Publish / Build and Publish Docker Image (push) Successful in 5m6s
2026-07-20 18:05:31 +03:00
Ultradesu 4b7756c36e Extend DHT scheme with content_id 2026-07-20 18:05:16 +03:00
Ultradesu 4381750c6e Extend DHT scheme
Build and Publish / Build and Publish Docker Image (push) Successful in 5m5s
2026-07-20 16:29:21 +03:00
Ultradesu 3485f643f4 Fixed DHT republish-after-ttl
Build and Publish / Build and Publish Docker Image (push) Successful in 4m58s
2026-07-20 14:19:11 +03:00
6 changed files with 443 additions and 209 deletions
Generated
+163 -151
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.6.1-fd" version = "0.6.6-fd"
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"] }
+214 -30
View File
@@ -15,13 +15,15 @@
mod serve; mod serve;
mod storage; 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;
@@ -41,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 {
/// Transport data directory; server-side DHT state and identity live in PostgreSQL. /// 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>>,
@@ -68,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),
@@ -136,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);
} }
_ => {} _ => {}
} }
@@ -149,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 {
@@ -208,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.
@@ -260,36 +274,39 @@ 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 {}, failed {})",
now_iso(),
stats.added,
stats.updated,
stats.removed,
stats.unchanged,
stats.failed
));
if stats.failed > 0 { if stats.failed > 0 {
self.set_error(Some(format!( self.set_error(Some(format!(
"{} item(s) failed to publish in the last sync", "{} item(s) failed to publish in the last sync",
@@ -298,24 +315,53 @@ impl Federation {
} else { } 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 {
@@ -323,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,
}); });
} }
@@ -357,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;
@@ -460,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
View File
@@ -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(),
}); });
} }
+8
View File
@@ -44,6 +44,14 @@ const SCHEMA: &[&str] = &[
ticket TEXT NOT NULL, ticket TEXT NOT NULL,
last_seen_ms BIGINT 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)] #[derive(Debug, Clone)]
+1 -1
View File
@@ -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;