Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b339aa921 | ||
|
|
42c772f735 | ||
|
|
e738086573 | ||
|
|
4b7756c36e |
Generated
+10
-9
@@ -690,9 +690,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.2"
|
version = "4.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -712,9 +712,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.1"
|
version = "4.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -1717,7 +1717,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#a897737978d476c223b66e65b960b70376084f2a"
|
source = "git+https://gt.hexor.cy/ab/frid.git#8ee1db9cf89ea604c6b8a8c3fe089a714c1e321f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1844,11 +1844,12 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.6.2-fd"
|
version = "0.6.5-fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"blake3",
|
||||||
"chrono",
|
"chrono",
|
||||||
"cot",
|
"cot",
|
||||||
"croner",
|
"croner",
|
||||||
@@ -2470,9 +2471,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.10.1"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3621,7 +3622,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#a897737978d476c223b66e65b960b70376084f2a"
|
source = "git+https://gt.hexor.cy/ab/frid.git#8ee1db9cf89ea604c6b8a8c3fe089a714c1e321f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.6.3-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"] }
|
||||||
|
|||||||
+136
-3
@@ -15,6 +15,7 @@
|
|||||||
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;
|
||||||
@@ -42,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>>,
|
||||||
@@ -69,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),
|
||||||
@@ -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 {
|
||||||
@@ -281,7 +295,7 @@ impl Federation {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_once(&self, service: &MusicDhtService) -> Result<SyncStats> {
|
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) => {
|
||||||
@@ -341,7 +355,7 @@ impl Federation {
|
|||||||
|
|
||||||
/// 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();
|
||||||
|
|
||||||
@@ -362,6 +376,7 @@ impl Federation {
|
|||||||
track_number: None,
|
track_number: None,
|
||||||
disc_number: None,
|
disc_number: None,
|
||||||
duration_seconds: None,
|
duration_seconds: None,
|
||||||
|
content_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +415,7 @@ impl Federation {
|
|||||||
track_number: None,
|
track_number: None,
|
||||||
disc_number: None,
|
disc_number: None,
|
||||||
duration_seconds: None,
|
duration_seconds: None,
|
||||||
|
content_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,16 +442,38 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
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
|
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,
|
||||||
@@ -448,12 +486,72 @@ impl Federation {
|
|||||||
track_number: row.get(6),
|
track_number: row.get(6),
|
||||||
disc_number: row.get(7),
|
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;
|
||||||
@@ -511,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 {
|
||||||
|
|||||||
+24
-18
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,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(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user