Files
furumi-web/src/import_playlists.rs
T
Aleksandr Bogomiakov 4141562e33
Build and Publish / Build and Publish Docker Image (push) Successful in 9m49s
Added stat page
2026-11-12 11:52:38 +00:00

281 lines
11 KiB
Rust

//! Durable playlist destinations for file and torrent imports.
use anyhow::{Context, bail};
use sqlx::PgPool;
pub async fn validate_target(
pool: &PgPool,
user_id: i64,
playlist_id: Option<i64>,
) -> anyhow::Result<()> {
if let Some(id) = playlist_id {
let owned: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM furumusic__playlist WHERE id=$1 AND owner_id=$2)",
)
.bind(id)
.bind(user_id)
.fetch_one(pool)
.await?;
if id <= 0 || !owned {
bail!("selected playlist does not exist or is not yours");
}
}
Ok(())
}
pub async fn register(
pool: &PgPool,
user_id: i64,
playlist_id: i64,
input_path: &str,
sha256: &str,
) -> anyhow::Result<()> {
validate_target(pool, user_id, Some(playlist_id)).await?;
let result = sqlx::query(
r#"INSERT INTO furumusic__import_playlist_target
(user_id, playlist_id, input_path, source_sha256)
VALUES ($1,$2,$3,$4)
ON CONFLICT (user_id, input_path) DO UPDATE SET source_sha256=EXCLUDED.source_sha256
WHERE furumusic__import_playlist_target.playlist_id=EXCLUDED.playlist_id
AND furumusic__import_playlist_target.source_sha256=EXCLUDED.source_sha256"#,
)
.bind(user_id)
.bind(playlist_id)
.bind(input_path)
.bind(sha256)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
bail!("this upload already has a different playlist destination");
}
Ok(())
}
/// Fingerprint deduplication may resolve a differently encoded file to an existing media ID.
pub async fn link_media(pool: &PgPool, source_sha256: &str, media_id: i64) -> anyhow::Result<()> {
if source_sha256.is_empty() {
return Ok(());
}
sqlx::query("UPDATE furumusic__import_playlist_target SET media_file_id=$2 WHERE source_sha256=$1 AND fulfilled_at IS NULL")
.bind(source_sha256).bind(media_id).execute(pool).await?;
reconcile(pool, None).await
}
/// Runs after imports and from background discovery, including after a restart.
/// Fulfilled requests are retained as receipts so later user removals stay removed.
pub async fn reconcile(pool: &PgPool, user_id: Option<i64>) -> anyhow::Result<()> {
let requests: Vec<(i64, i64, String)> = sqlx::query_as(
"SELECT user_id,playlist_id,input_path FROM furumusic__import_playlist_target WHERE fulfilled_at IS NULL AND ($1::bigint IS NULL OR user_id=$1) ORDER BY user_id,playlist_id,input_path"
).bind(user_id).fetch_all(pool).await?;
for (user_id, playlist_id, path) in requests {
let mut transaction = pool.begin().await?;
let pending: Option<String> = sqlx::query_scalar(
"SELECT input_path FROM furumusic__import_playlist_target WHERE user_id=$1 AND input_path=$2 AND fulfilled_at IS NULL FOR UPDATE"
).bind(user_id).bind(&path).fetch_optional(&mut *transaction).await?;
if pending.is_none() {
continue;
}
let track_ids: Vec<i64> = sqlx::query_scalar(r#"
SELECT DISTINCT track.id FROM furumusic__import_playlist_target request
JOIN furumusic__media_file media ON media.id=request.media_file_id
OR (request.media_file_id IS NULL AND media.sha256_hash=request.source_sha256 AND media.file_type='audio')
JOIN furumusic__track track ON track.audio_file_id=media.id
WHERE request.user_id=$1 AND request.input_path=$2 ORDER BY track.id
"#).bind(user_id).bind(&path).fetch_all(&mut *transaction).await?;
if track_ids.is_empty() {
continue;
}
let mut added = Vec::new();
for track_id in track_ids {
if append_in_transaction(&mut transaction, user_id, playlist_id, track_id).await? {
added.push(track_id);
}
}
sqlx::query("UPDATE furumusic__import_playlist_target SET fulfilled_at=$3 WHERE user_id=$1 AND input_path=$2")
.bind(user_id).bind(path).bind(now_string()).execute(&mut *transaction).await?;
transaction.commit().await?;
record_added(pool, user_id, playlist_id, &added).await;
}
Ok(())
}
fn now_string() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
pub(crate) async fn append_track_to_target_playlist(
pool: &PgPool,
user_id: i64,
playlist_id: i64,
track_id: i64,
) -> anyhow::Result<bool> {
let mut transaction = pool.begin().await?;
let added = append_in_transaction(&mut transaction, user_id, playlist_id, track_id).await?;
transaction.commit().await?;
if added {
record_added(pool, user_id, playlist_id, &[track_id]).await;
}
Ok(added)
}
async fn append_in_transaction(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
user_id: i64,
playlist_id: i64,
track_id: i64,
) -> anyhow::Result<bool> {
let playlist: Option<i64> = sqlx::query_scalar(
r#"SELECT id FROM furumusic__playlist
WHERE id = $1 AND owner_id = $2
FOR UPDATE"#,
)
.bind(playlist_id)
.bind(user_id)
.fetch_optional(&mut **transaction)
.await?;
if playlist.is_none() {
return Ok(false);
}
let already_present: bool = sqlx::query_scalar(
r#"SELECT EXISTS (
SELECT 1 FROM furumusic__playlist_track
WHERE playlist_id = $1 AND track_id = $2
)"#,
)
.bind(playlist_id)
.bind(track_id)
.fetch_one(&mut **transaction)
.await?;
if already_present {
return Ok(false);
}
let position: i64 = sqlx::query_scalar(
r#"SELECT COALESCE(MAX(position), -1)::bigint + 1
FROM furumusic__playlist_track WHERE playlist_id = $1"#,
)
.bind(playlist_id)
.fetch_one(&mut **transaction)
.await?;
let position = i32::try_from(position).context("target playlist has too many tracks")?;
let now = now_string();
sqlx::query(
r#"INSERT INTO furumusic__playlist_track
(playlist_id, track_id, position, added_at, added_by_user_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(playlist_id)
.bind(track_id)
.bind(position)
.bind(&now)
.bind(user_id)
.execute(&mut **transaction)
.await?;
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
.bind(&now)
.bind(playlist_id)
.execute(&mut **transaction)
.await?;
Ok(true)
}
async fn record_added(pool: &PgPool, user_id: i64, playlist_id: i64, track_ids: &[i64]) {
if track_ids.is_empty() {
return;
}
if let Err(error) = crate::federation::devices::record_playlist_tracks_added(
pool,
user_id,
playlist_id,
track_ids,
)
.await
{
tracing::warn!(playlist_id, %error, "federation operation for an automatic import playlist addition was not recorded");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "requires disposable PostgreSQL in FURU_IMPORT_TEST_DATABASE_URL"]
async fn import_destinations_are_durable_owned_and_idempotent() {
let url = std::env::var("FURU_IMPORT_TEST_DATABASE_URL").unwrap();
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.unwrap();
sqlx::raw_sql(r#"
CREATE TEMP TABLE furumusic__playlist(id bigint PRIMARY KEY,owner_id bigint,updated_at text);
CREATE TEMP TABLE furumusic__media_file(id bigint PRIMARY KEY,sha256_hash text,file_type text);
CREATE TEMP TABLE furumusic__track(id bigint PRIMARY KEY,audio_file_id bigint);
CREATE TEMP TABLE furumusic__playlist_track(playlist_id bigint,track_id bigint,position int,added_at text,added_by_user_id bigint);
CREATE TEMP TABLE furumusic__import_playlist_target(user_id bigint,playlist_id bigint REFERENCES furumusic__playlist ON DELETE CASCADE,input_path text,source_sha256 text,media_file_id bigint,fulfilled_at text,PRIMARY KEY(user_id,input_path));
INSERT INTO furumusic__playlist VALUES (10,1,''),(20,2,'');
"#).execute(&pool).await.unwrap();
assert!(validate_target(&pool, 1, Some(20)).await.is_err());
assert!(validate_target(&pool, 1, Some(999)).await.is_err());
validate_target(&pool, 1, None).await.unwrap();
register(&pool, 1, 10, "batch/a.flac", "hash-a")
.await
.unwrap();
register(&pool, 1, 10, "batch/a.flac", "hash-a")
.await
.unwrap();
assert!(
register(&pool, 1, 10, "batch/a.flac", "changed-hash")
.await
.is_err()
);
reconcile(&pool, None).await.unwrap();
let count = || {
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM furumusic__playlist_track")
.fetch_one(&pool)
};
assert_eq!(count().await.unwrap(), 0);
sqlx::raw_sql("INSERT INTO furumusic__media_file VALUES (100,'hash-a','audio'); INSERT INTO furumusic__track VALUES (101,100),(102,100);").execute(&pool).await.unwrap();
reconcile(&pool, Some(2)).await.unwrap();
assert_eq!(count().await.unwrap(), 0);
reconcile(&pool, Some(1)).await.unwrap();
assert_eq!(count().await.unwrap(), 2);
// A second upload of identical audio must not append duplicates.
register(&pool, 1, 10, "torrent/b.flac", "hash-a")
.await
.unwrap();
reconcile(&pool, None).await.unwrap();
assert_eq!(count().await.unwrap(), 2);
sqlx::query("DELETE FROM furumusic__playlist_track WHERE track_id=101")
.execute(&pool)
.await
.unwrap();
reconcile(&pool, None).await.unwrap();
assert_eq!(count().await.unwrap(), 1);
// Fingerprint matching can map a differently encoded upload to the same media.
register(&pool, 2, 20, "batch/a.mp3", "different-encoding")
.await
.unwrap();
link_media(&pool, "different-encoding", 100).await.unwrap();
assert_eq!(count().await.unwrap(), 3);
let positions: Vec<i32> = sqlx::query_scalar(
"SELECT position FROM furumusic__playlist_track WHERE playlist_id=20 ORDER BY position",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(positions, vec![0, 1]);
// Deleting a destination removes pending work without blocking future imports.
register(&pool, 2, 20, "batch/pending.wav", "pending")
.await
.unwrap();
sqlx::query("DELETE FROM furumusic__playlist WHERE id=20")
.execute(&pool)
.await
.unwrap();
reconcile(&pool, None).await.unwrap();
pool.close().await;
}
}