use std::collections::HashSet;
use config::{AppConfig, Browser, MusicServer, MusicService, SavedServer, Source};
use sqlx::SqlitePool;
use crate::DbError;
const RECENT_LIMIT: i64 = 50;
pub async fn load_config(pool: &SqlitePool) -> Result<Option<AppConfig>, DbError> {
let Some(json): Option<String> =
sqlx::query_scalar!("SELECT json FROM app_config WHERE id = 1")
.fetch_optional(pool)
.await?
else {
return Ok(None);
};
let mut cfg: AppConfig = serde_json::from_str(&json)?;
cfg.migrate_home_sections();
cfg.migrate_sidebar_order();
cfg.migrate_registry_paths();
let rows = sqlx::query!(
"SELECT id, name, url, service, access_token, user_id, yt_browser, yt_anonymous \
FROM servers"
)
.fetch_all(pool)
.await?;
cfg.servers = rows
.iter()
.map(|r| SavedServer {
id: r.id.clone(),
name: r.name.clone(),
url: r.url.clone(),
service: parse_service(&r.service),
yt_browser: parse_browser(r.yt_browser.as_deref()),
yt_anonymous: r.yt_anonymous != 0,
})
.collect();
cfg.server = cfg.active_source.server_id().and_then(|active| {
rows.iter().find(|r| r.id == active).map(|r| MusicServer {
name: r.name.clone(),
url: r.url.clone(),
service: parse_service(&r.service),
access_token: r.access_token.clone(),
user_id: r.user_id.clone(),
id: Some(r.id.clone()),
yt_browser: parse_browser(r.yt_browser.as_deref()),
yt_anonymous: r.yt_anonymous != 0,
})
});
let counts = sqlx::query!("SELECT track_key, count FROM listen_counts")
.fetch_all(pool)
.await?;
cfg.listen_counts = counts
.into_iter()
.map(|r| (r.track_key, r.count.max(0) as u64))
.collect();
Ok(Some(cfg))
}
#[tracing::instrument(name = "config.save", skip_all)]
pub async fn save_config(pool: &SqlitePool, cfg: &AppConfig) -> Result<(), DbError> {
let now = now_secs();
let mut tx = pool.begin().await?;
for s in &cfg.servers {
let service = service_str(s.service);
let browser = s.yt_browser.map(browser_str);
let anon = s.yt_anonymous as i64;
sqlx::query!(
"INSERT INTO servers (id, name, url, service, yt_browser, yt_anonymous, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
ON CONFLICT(id) DO UPDATE SET name=?2, url=?3, service=?4, yt_browser=?5, \
yt_anonymous=?6, updated_at=?7",
s.id,
s.name,
s.url,
service,
browser,
anon,
now
)
.execute(&mut *tx)
.await?;
}
let mut active_id: Option<String> = cfg.active_source.server_id().map(String::from);
if let Some(srv) = &cfg.server {
let id = srv
.id
.clone()
.or_else(|| cfg.active_source.server_id().map(String::from))
.unwrap_or_else(|| format!("legacy-{}", service_str(srv.service)));
let service = service_str(srv.service);
let browser = srv.yt_browser.map(browser_str);
let anon = srv.yt_anonymous as i64;
let auth = if srv.access_token.is_some() || srv.yt_anonymous {
"active"
} else {
"unauthenticated"
};
sqlx::query!(
"INSERT INTO servers \
(id, name, url, service, access_token, user_id, yt_browser, yt_anonymous, auth_state, cred_updated_at, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10) \
ON CONFLICT(id) DO UPDATE SET name=?2, url=?3, service=?4, access_token=?5, \
user_id=?6, yt_browser=?7, yt_anonymous=?8, auth_state=?9, cred_updated_at=?10, updated_at=?10",
id,
srv.name,
srv.url,
service,
srv.access_token,
srv.user_id,
browser,
anon,
auth,
now
)
.execute(&mut *tx)
.await?;
active_id = Some(id);
}
let keep: HashSet<&str> = cfg
.servers
.iter()
.map(|s| s.id.as_str())
.chain(active_id.as_deref())
.collect();
let existing: Vec<String> = sqlx::query_scalar!("SELECT id FROM servers")
.fetch_all(&mut *tx)
.await?;
for id in existing {
if !keep.contains(id.as_str()) {
sqlx::query!("DELETE FROM servers WHERE id = ?1", id)
.execute(&mut *tx)
.await?;
}
}
let mut blob = serde_json::to_value(cfg)?;
if let Some(obj) = blob.as_object_mut() {
obj.remove("server");
obj.remove("servers");
obj.remove("listen_counts");
obj.insert(
"active_source".into(),
match &active_id {
Some(id) => serde_json::json!({ "Server": id }),
None => serde_json::json!("Local"),
},
);
}
let blob_str = serde_json::to_string(&blob)?;
sqlx::query!(
"INSERT INTO app_config (id, json) VALUES (1, ?1) \
ON CONFLICT(id) DO UPDATE SET json = ?1",
blob_str
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn load_server(pool: &SqlitePool, id: &str) -> Result<Option<MusicServer>, DbError> {
let row = sqlx::query!(
"SELECT id, name, url, service, access_token, user_id, yt_browser, yt_anonymous \
FROM servers WHERE id = ?1",
id
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| MusicServer {
name: r.name,
url: r.url,
service: parse_service(&r.service),
access_token: r.access_token,
user_id: r.user_id,
id: Some(r.id),
yt_browser: parse_browser(r.yt_browser.as_deref()),
yt_anonymous: r.yt_anonymous != 0,
}))
}
pub async fn bump_listen_count(pool: &SqlitePool, key: &str) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO listen_counts (track_key, count) VALUES (?1, 1) \
ON CONFLICT(track_key) DO UPDATE SET count = count + 1",
key
)
.execute(pool)
.await?;
Ok(())
}
pub async fn recently_played(
pool: &SqlitePool,
source: &Source,
limit: u32,
) -> Result<Vec<String>, DbError> {
let rows: Vec<String> = sqlx::query_scalar(
"SELECT track_key FROM recently_played WHERE source = ?1 \
ORDER BY played_at DESC LIMIT ?2",
)
.bind(source.as_str())
.bind(limit as i64)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn push_recent(pool: &SqlitePool, source: &Source, key: &str) -> Result<(), DbError> {
let src = source.as_str();
let mut tx = pool.begin().await?;
let next: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(played_at), 0) + 1 FROM recently_played WHERE source = ?1",
)
.bind(src)
.fetch_one(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO recently_played (source, track_key, played_at) VALUES (?1, ?2, ?3) \
ON CONFLICT(source, track_key) DO UPDATE SET played_at = ?3",
)
.bind(src)
.bind(key)
.bind(next)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM recently_played WHERE source = ?1 AND track_key NOT IN \
(SELECT track_key FROM recently_played WHERE source = ?1 \
ORDER BY played_at DESC LIMIT ?2)",
)
.bind(src)
.bind(RECENT_LIMIT)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
fn parse_service(s: &str) -> MusicService {
match s {
"Subsonic" => MusicService::Subsonic,
"Custom" => MusicService::Custom,
"YtMusic" => MusicService::YtMusic,
"SoundCloud" => MusicService::SoundCloud,
_ => MusicService::Jellyfin,
}
}
fn service_str(s: MusicService) -> &'static str {
match s {
MusicService::Jellyfin => "Jellyfin",
MusicService::Subsonic => "Subsonic",
MusicService::Custom => "Custom",
MusicService::YtMusic => "YtMusic",
MusicService::SoundCloud => "SoundCloud",
}
}
fn parse_browser(s: Option<&str>) -> Option<Browser> {
match s {
Some("chrome") => Some(Browser::Chrome),
Some("chromium") => Some(Browser::Chromium),
Some("brave") => Some(Browser::Brave),
Some("edge") => Some(Browser::Edge),
Some("vivaldi") => Some(Browser::Vivaldi),
_ => None,
}
}
fn browser_str(b: Browser) -> String {
b.id().to_string()
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}