use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use sqlx::SqlitePool;
use super::{open_pool, with_ext};
use crate::{DbError, ImportReport};
use reader::models::{Track, TrackId};
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
pub(super) async fn run_migrations(pool: &SqlitePool) -> Result<(), DbError> {
match MIGRATOR.run(pool).await {
Ok(()) => Ok(()),
Err(sqlx::migrate::MigrateError::VersionMismatch(_)) => {
reconcile_eol_checksums(pool).await?;
MIGRATOR.run(pool).await.map_err(Into::into)
}
Err(e) => Err(e.into()),
}
}
async fn reconcile_eol_checksums(pool: &SqlitePool) -> Result<(), DbError> {
use sha2::{Digest, Sha384};
let stored: HashMap<i64, Vec<u8>> =
sqlx::query_as::<_, (i64, Vec<u8>)>("SELECT version, checksum FROM _sqlx_migrations")
.fetch_all(pool)
.await?
.into_iter()
.collect();
for m in MIGRATOR.iter() {
let Some(stored_ck) = stored.get(&m.version) else {
continue;
};
if stored_ck.as_slice() == m.checksum.as_ref() {
continue;
}
let lf = m.sql.replace("\r\n", "\n");
let crlf = lf.replace('\n', "\r\n");
let matches_eol_variant = [lf.as_bytes(), crlf.as_bytes()]
.into_iter()
.any(|bytes| Sha384::digest(bytes).as_slice() == stored_ck.as_slice());
if matches_eol_variant {
sqlx::query("UPDATE _sqlx_migrations SET checksum = ?1 WHERE version = ?2")
.bind(m.checksum.as_ref())
.bind(m.version)
.execute(pool)
.await?;
tracing::warn!(
version = m.version,
"reconciled migration checksum (line-ending-only difference)"
);
}
}
Ok(())
}
pub(super) async fn snapshot_if_pending(path: &Path) {
if !path.exists() {
return; }
let Ok(pool) = open_pool(path).await else {
return;
};
let applied: Option<i64> = sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations")
.fetch_one(&pool)
.await
.unwrap_or(None);
let available = MIGRATOR.iter().map(|m| m.version).max();
let pending = match (applied, available) {
(Some(a), Some(v)) => v > a,
(None, Some(_)) => false, _ => false,
};
pool.close().await;
if !pending {
return;
}
let stamp = applied.unwrap_or(0);
if backup_name(path, stamp, "").exists() {
return;
}
for ext in ["", "-wal", "-shm"] {
let src = with_ext(path, ext);
if src.exists() {
let dst = backup_name(path, stamp, ext);
if let Err(e) = std::fs::copy(&src, &dst) {
tracing::warn!(error = %e, src = %src.display(), "db: pre-migration snapshot failed");
}
}
}
tracing::info!(
applied = stamp,
"db: snapshotted before applying pending migrations"
);
}
fn backup_name(path: &Path, stamp: i64, suffix: &str) -> std::path::PathBuf {
let mut s = path.as_os_str().to_os_string();
s.push(format!(".pre-{stamp}.bak{suffix}"));
std::path::PathBuf::from(s)
}
const LEGACY_FILES: [&str; 5] = [
"config.json",
"library.json",
"playlists.json",
"favorites.json",
"queue_state.json",
];
fn legacy_source(config_dir: &Path, name: &str) -> Option<std::path::PathBuf> {
let plain = config_dir.join(name);
if plain.exists() {
return Some(plain);
}
let bak = config_dir.join(format!("{name}.bak"));
bak.exists().then_some(bak)
}
#[tracing::instrument(skip_all)]
pub async fn run_json_import(
pool: &SqlitePool,
config_dir: &Path,
) -> Result<ImportReport, DbError> {
if db_has_data(pool).await? {
return Ok(ImportReport::default());
}
if !LEGACY_FILES
.iter()
.any(|f| legacy_source(config_dir, f).is_some())
{
return Ok(ImportReport::default());
}
let mut consumed: Vec<&str> = Vec::new();
let read_src = |name: &str| legacy_source(config_dir, name);
let cfg_val: serde_json::Value = match read_src("config.json") {
Some(p) => match read_json_tolerant(&p) {
Some(v) => {
consumed.push("config.json");
v
}
None => serde_json::Value::Null,
},
None => serde_json::Value::Null,
};
let lib: LegacyLibrary = match read_src("library.json") {
Some(p) => match read_json_tolerant(&p) {
Some(v) => {
consumed.push("library.json");
v
}
None => LegacyLibrary::default(),
},
None => LegacyLibrary::default(),
};
let plists: LegacyPlaylists = match read_src("playlists.json") {
Some(p) => match read_json_tolerant(&p) {
Some(v) => {
consumed.push("playlists.json");
v
}
None => LegacyPlaylists::default(),
},
None => LegacyPlaylists::default(),
};
let favs: LegacyFavorites = match read_src("favorites.json") {
Some(p) => match read_json_tolerant(&p) {
Some(v) => {
consumed.push("favorites.json");
v
}
None => LegacyFavorites::default(),
},
None => LegacyFavorites::default(),
};
let queue: LegacyQueue = match read_src("queue_state.json") {
Some(p) => match read_json_tolerant(&p) {
Some(v) => {
consumed.push("queue_state.json");
v
}
None => LegacyQueue::default(),
},
None => LegacyQueue::default(),
};
let now = now_secs();
let mut tx = pool.begin().await?;
let active_server_id = import_servers(&mut tx, &cfg_val, now).await?;
let server_src = active_server_id.clone();
import_config_blob(&mut tx, &cfg_val, &active_server_id, &lib).await?;
import_listen_counts(&mut tx, &cfg_val).await?;
import_recently_played(&mut tx, &cfg_val, &active_server_id).await?;
if lib.last_yt_sync_at.is_some() || lib.last_yt_playlists_sync_at.is_some() {
let stamps = serde_json::json!({
"last_yt_sync_at": lib.last_yt_sync_at,
"last_yt_playlists_sync_at": lib.last_yt_playlists_sync_at,
})
.to_string();
sqlx::query!(
"INSERT INTO metadata_cache (cache_key, kind, payload) VALUES ('yt_sync', 'timestamps', ?1) \
ON CONFLICT(cache_key, kind) DO UPDATE SET payload = ?1",
stamps
)
.execute(&mut *tx)
.await?;
}
if server_src.is_none()
&& (!lib.jellyfin_tracks.is_empty()
|| !plists.jellyfin_playlists.is_empty()
|| !favs.jellyfin_favorites.is_empty())
{
tracing::warn!(
"db: legacy server data present but no signed-in server — skipping it (re-syncs after sign-in)"
);
}
for a in &lib.albums {
insert_album(&mut tx, "local", a).await?;
}
if let Some(sid) = &server_src {
for a in &lib.jellyfin_albums {
insert_album(&mut tx, sid, a).await?;
}
}
for lt in &lib.tracks {
if let Some(t) = legacy_to_track(lt) {
insert_track(&mut tx, "local", &t).await?;
}
}
if let Some(sid) = &server_src {
for lt in &lib.jellyfin_tracks {
if let Some(t) = legacy_to_track(lt) {
insert_track(&mut tx, sid, &t).await?;
}
}
}
import_artist_images(&mut tx, "server", &lib.server_artist_images).await?;
import_artist_images(&mut tx, "local", &lib.local_artist_images).await?;
import_artist_images(&mut tx, "custom", &lib.custom_artist_images).await?;
for (i, p) in plists.playlists.iter().enumerate() {
let pk = insert_playlist(
&mut tx,
"local",
&p.id,
&p.name,
&p.cover_path,
None,
i as i64,
)
.await?;
insert_playlist_tracks(&mut tx, pk, &p.tracks).await?;
}
if let Some(sid) = &server_src {
for (i, p) in plists.jellyfin_playlists.iter().enumerate() {
let pk = insert_playlist(
&mut tx,
sid,
&p.id,
&p.name,
&p.cover_path,
p.image_tag.as_deref(),
i as i64,
)
.await?;
insert_playlist_tracks(&mut tx, pk, &p.tracks).await?;
}
}
for f in &plists.folders {
sqlx::query!(
"INSERT OR IGNORE INTO folders (id, source, name) VALUES (?1, 'local', ?2)",
f.id,
f.name
)
.execute(&mut *tx)
.await?;
for (pos, pl) in f.playlist_ids.iter().enumerate() {
let pos = pos as i64;
sqlx::query!(
"INSERT OR IGNORE INTO folder_playlists (folder_id, playlist_ref, position) \
VALUES (?1, ?2, ?3)",
f.id,
pl,
pos
)
.execute(&mut *tx)
.await?;
}
}
for r in &favs.local_favorites {
insert_favorite(&mut tx, "local", r, now).await?;
}
if let Some(sid) = server_src.as_deref() {
for r in &favs.jellyfin_favorites {
insert_favorite(&mut tx, sid, r, now).await?;
}
if !favs.jellyfin_favorites.is_empty() {
let now_s = now.to_string();
sqlx::query!(
"INSERT INTO metadata_cache (cache_key, kind, payload) VALUES ('fav_pull', ?1, ?2) \
ON CONFLICT(cache_key, kind) DO UPDATE SET payload = ?2",
sid,
now_s
)
.execute(&mut *tx)
.await?;
}
}
let queue_tracks: Vec<Track> = queue.queue.iter().filter_map(legacy_to_track).collect();
let queue_json = serde_json::to_string(&queue_tracks)?;
let shuffle_json = serde_json::to_string(&queue.shuffle_order)?;
let cqi = queue.current_queue_index;
let prog = queue.progress_secs;
let shuffle_on = queue.shuffle_enabled as i64;
let ver = queue.version as i64;
sqlx::query!(
"INSERT INTO queue_state \
(id, version, queue_json, current_queue_index, progress_secs, shuffle_order_json, shuffle_enabled) \
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT(id) DO UPDATE SET version=?1, queue_json=?2, current_queue_index=?3, \
progress_secs=?4, shuffle_order_json=?5, shuffle_enabled=?6",
ver,
queue_json,
cqi,
prog,
shuffle_json,
shuffle_on
)
.execute(&mut *tx)
.await?;
let consumed_json = serde_json::to_string(&consumed)?;
sqlx::query!(
"INSERT INTO metadata_cache (cache_key, kind, payload) VALUES ('legacy_import', 'files', ?1) \
ON CONFLICT(cache_key, kind) DO UPDATE SET payload = ?1",
consumed_json
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
let report = ImportReport {
ran: true,
tracks: count(pool, "SELECT COUNT(*) FROM tracks").await,
albums: count(pool, "SELECT COUNT(*) FROM albums").await,
playlists: count(pool, "SELECT COUNT(*) FROM playlists").await,
favorites: count(pool, "SELECT COUNT(*) FROM favorites").await,
servers: count(pool, "SELECT COUNT(*) FROM servers").await,
};
tracing::info!(
tracks = report.tracks,
albums = report.albums,
playlists = report.playlists,
favorites = report.favorites,
servers = report.servers,
"db: legacy JSON import complete"
);
Ok(report)
}
pub async fn finalize_migration(pool: &SqlitePool, config_dir: &Path) -> Result<usize, DbError> {
let consumed: Option<String> = sqlx::query_scalar!(
"SELECT payload FROM metadata_cache WHERE cache_key = 'legacy_import' AND kind = 'files'"
)
.fetch_optional(pool)
.await?
.flatten();
let Some(consumed) = consumed else {
return Ok(0);
};
let consumed: Vec<String> = serde_json::from_str(&consumed).unwrap_or_default();
let mut renamed = 0;
for f in LEGACY_FILES {
let src = config_dir.join(f);
if consumed.iter().any(|c| c == f) && src.exists() {
backup_aside(&src);
renamed += 1;
}
}
let _ = std::fs::remove_file(config_dir.join(".db_migrated"));
Ok(renamed)
}
async fn import_servers(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
cfg: &serde_json::Value,
now: i64,
) -> Result<Option<String>, DbError> {
if let Some(arr) = cfg.get("servers").and_then(|v| v.as_array()) {
for s in arr {
let id = str_at(s, "id");
if id.is_empty() {
continue;
}
let name = str_at(s, "name");
let url = str_at(s, "url");
let service = service_at(s);
let yt_browser = opt_str_at(s, "yt_browser");
let yt_anon = s
.get("yt_anonymous")
.and_then(|v| v.as_bool())
.unwrap_or(false) as i64;
sqlx::query!(
"INSERT OR IGNORE INTO servers (id, name, url, service, yt_browser, yt_anonymous) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
id,
name,
url,
service,
yt_browser,
yt_anon
)
.execute(&mut **tx)
.await?;
}
}
let Some(srv) = cfg.get("server").filter(|v| !v.is_null()) else {
return Ok(None);
};
let name = str_at(srv, "name");
let url = str_at(srv, "url");
let service = service_at(srv);
let token = opt_str_at(srv, "access_token");
let user_id = opt_str_at(srv, "user_id");
let yt_browser = opt_str_at(srv, "yt_browser");
let yt_anon = srv
.get("yt_anonymous")
.and_then(|v| v.as_bool())
.unwrap_or(false) as i64;
let resolved = opt_str_at(srv, "id")
.or_else(|| {
cfg.get("servers")
.and_then(|v| v.as_array())
.and_then(|arr| {
arr.iter()
.find(|s| str_at(s, "url") == url && service_at(s) == service)
.map(|s| str_at(s, "id"))
})
.filter(|s| !s.is_empty())
})
.unwrap_or_else(|| format!("legacy-{service}"));
let auth_state = if token.is_some() || yt_anon == 1 {
"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) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?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",
resolved,
name,
url,
service,
token,
user_id,
yt_browser,
yt_anon,
auth_state,
now
)
.execute(&mut **tx)
.await?;
Ok(Some(resolved))
}
async fn import_config_blob(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
cfg: &serde_json::Value,
active_server_id: &Option<String>,
lib: &LegacyLibrary,
) -> Result<(), DbError> {
let mut blob = if cfg.is_null() {
serde_json::json!({})
} else {
cfg.clone()
};
if let Some(obj) = blob.as_object_mut() {
obj.remove("server");
obj.remove("servers");
obj.remove("listen_counts");
obj.remove("active_server_id");
obj.insert(
"active_source".into(),
match active_server_id {
Some(id) => serde_json::json!({ "Server": id }),
None => serde_json::json!("Local"),
},
);
obj.insert(
"last_yt_sync_at".into(),
serde_json::json!(lib.last_yt_sync_at),
);
obj.insert(
"last_yt_playlists_sync_at".into(),
serde_json::json!(lib.last_yt_playlists_sync_at),
);
}
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?;
Ok(())
}
async fn import_listen_counts(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
cfg: &serde_json::Value,
) -> Result<(), DbError> {
let Some(map) = cfg.get("listen_counts").and_then(|v| v.as_object()) else {
return Ok(());
};
for (k, v) in map {
let key = TrackId::from_legacy_path(k).uid();
let count = v.as_i64().unwrap_or(0);
sqlx::query!(
"INSERT INTO listen_counts (track_key, count) VALUES (?1, ?2) \
ON CONFLICT(track_key) DO UPDATE SET count = count + ?2",
key,
count
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn import_recently_played(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
cfg: &serde_json::Value,
active_server_id: &Option<String>,
) -> Result<(), DbError> {
let mut lists: Vec<(String, &str)> = vec![("local".to_string(), "recently_played")];
if let Some(id) = active_server_id {
lists.push((id.clone(), "recently_played_server"));
}
for (source, field) in lists {
let Some(arr) = cfg.get(field).and_then(|v| v.as_array()) else {
continue;
};
let n = arr.len() as i64;
for (i, item) in arr.iter().enumerate() {
if let Some(key) = item.as_str() {
let rank = n - i as i64; sqlx::query(
"INSERT OR IGNORE INTO recently_played (source, track_key, played_at) VALUES (?1, ?2, ?3)",
)
.bind(&source)
.bind(key)
.bind(rank)
.execute(&mut **tx)
.await?;
}
}
}
Ok(())
}
async fn import_artist_images(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
kind: &str,
map: &HashMap<String, String>,
) -> Result<(), DbError> {
for (artist, image) in map {
sqlx::query!(
"INSERT OR IGNORE INTO artist_images (artist_norm, kind, image_ref) VALUES (?1, ?2, ?3)",
artist,
kind,
image
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn insert_album(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
source: &str,
a: &LegacyAlbum,
) -> Result<(), DbError> {
let manual = a.manual_cover as i64;
sqlx::query!(
"INSERT OR IGNORE INTO albums \
(source, source_album_id, title, artist, genre, year, cover_path, manual_cover) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
source,
a.id,
a.title,
a.artist,
a.genre,
a.year,
a.cover_path,
manual
)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn insert_track(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
source: &str,
t: &Track,
) -> Result<(), DbError> {
let track_key = t.id.key().into_owned();
let path = t.id.local_path().map(|p| p.to_string_lossy().into_owned());
let service = t.id.service().map(|s| service_str(s).to_string());
let duration = t.duration as i64;
let khz = t.khz as i64;
let bitrate = t.bitrate as i64;
let track_number = t.track_number.map(|n| n as i64);
let disc_number = t.disc_number.map(|n| n as i64);
let artists_json = serde_json::to_string(&t.artists)?;
sqlx::query!(
"INSERT OR IGNORE INTO tracks \
(source, track_key, path, service, source_album_id, title, artist, album, duration, \
khz, bitrate, track_number, disc_number, mb_release_id, mb_recording_id, mb_track_id, \
playlist_item_id, artists_json, cover_path) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
source,
track_key,
path,
service,
t.album_id,
t.title,
t.artist,
t.album,
duration,
khz,
bitrate,
track_number,
disc_number,
t.musicbrainz_release_id,
t.musicbrainz_recording_id,
t.musicbrainz_track_id,
t.playlist_item_id,
artists_json,
t.cover
)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn insert_playlist(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
source: &str,
source_pl_id: &str,
name: &str,
cover_path: &Option<String>,
image_tag: Option<&str>,
position: i64,
) -> Result<i64, DbError> {
let rec = sqlx::query!(
"INSERT INTO playlists (source, source_pl_id, name, cover_path, image_tag, position) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT(source, source_pl_id) DO UPDATE SET name=?3, cover_path=?4, image_tag=?5, position=?6 \
RETURNING rowid_pk",
source,
source_pl_id,
name,
cover_path,
image_tag,
position
)
.fetch_one(&mut **tx)
.await?;
Ok(rec.rowid_pk)
}
async fn insert_playlist_tracks(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
playlist_pk: i64,
refs: &[String],
) -> Result<(), DbError> {
for (pos, r) in refs.iter().enumerate() {
let pos = pos as i64;
sqlx::query!(
"INSERT OR IGNORE INTO playlist_tracks (playlist_pk, position, track_ref) \
VALUES (?1, ?2, ?3)",
playlist_pk,
pos,
r
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn insert_favorite(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
server_id: &str,
ref_: &str,
now: i64,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT OR IGNORE INTO favorites (server_id, ref, created_at) VALUES (?1, ?2, ?3)",
server_id,
ref_,
now
)
.execute(&mut **tx)
.await?;
Ok(())
}
fn legacy_to_track(l: &LegacyTrack) -> Option<Track> {
let (id, cover) = if let Some(id) = &l.id {
(id.clone(), l.cover.clone())
} else if let Some(path) = &l.path {
(TrackId::from_legacy_path(path), split_legacy_cover(path))
} else {
return None;
};
Some(Track {
id,
cover,
album_id: l.album_id.clone(),
title: l.title.clone(),
artist: l.artist.clone(),
album: l.album.clone(),
duration: l.duration,
khz: l.khz,
bitrate: l.bitrate,
track_number: l.track_number,
disc_number: l.disc_number,
musicbrainz_release_id: l.musicbrainz_release_id.clone(),
musicbrainz_recording_id: l.musicbrainz_recording_id.clone(),
musicbrainz_track_id: l.musicbrainz_track_id.clone(),
playlist_item_id: l.playlist_item_id.clone(),
artists: l.artists.clone(),
})
}
fn split_legacy_cover(path: &str) -> Option<String> {
for prefix in ["ytmusic", "jellyfin", "subsonic", "custom"] {
if let Some(rest) = path.strip_prefix(prefix).and_then(|r| r.strip_prefix(':')) {
return rest
.split_once(':')
.map(|(_, cover)| cover.to_string())
.filter(|c| !c.is_empty());
}
}
None
}
fn service_str(s: config::MusicService) -> &'static str {
match s {
config::MusicService::Jellyfin => "Jellyfin",
config::MusicService::Subsonic => "Subsonic",
config::MusicService::Custom => "Custom",
config::MusicService::YtMusic => "YtMusic",
config::MusicService::SoundCloud => "SoundCloud",
}
}
fn str_at(v: &serde_json::Value, key: &str) -> String {
v.get(key)
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string()
}
fn opt_str_at(v: &serde_json::Value, key: &str) -> Option<String> {
v.get(key)
.and_then(|x| x.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
fn service_at(v: &serde_json::Value) -> String {
let s = str_at(v, "service");
if s.is_empty() {
"Jellyfin".to_string()
} else {
s
}
}
async fn db_has_data(pool: &SqlitePool) -> Result<bool, DbError> {
let has_cfg: Option<i64> = sqlx::query_scalar!("SELECT 1 FROM app_config WHERE id = 1")
.fetch_optional(pool)
.await?;
let ntracks: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM tracks")
.fetch_one(pool)
.await?;
Ok(has_cfg.is_some() || ntracks > 0)
}
async fn count(pool: &SqlitePool, sql: &str) -> usize {
sqlx::query_scalar::<_, i64>(sql)
.fetch_one(pool)
.await
.unwrap_or(0)
.max(0) as usize
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn read_json_tolerant<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
let s = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, file = %path.display(), "db: unreadable legacy json — skipping it");
return None;
}
};
match serde_json::from_str(&s) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(error = %e, file = %path.display(), "db: corrupt legacy json — skipping it (file left in place)");
None
}
}
}
fn backup_aside(src: &Path) {
let mut dst = src.as_os_str().to_os_string();
dst.push(".bak");
let dst = std::path::PathBuf::from(dst);
if dst.exists() {
let mut aged = dst.as_os_str().to_os_string();
aged.push(format!(".{}", now_secs()));
if let Err(e) = std::fs::rename(&dst, std::path::PathBuf::from(aged)) {
tracing::warn!(error = %e, "db: could not age old .bak aside");
return;
}
}
if let Err(e) = std::fs::rename(src, &dst) {
tracing::warn!(error = %e, src = %src.display(), "db: could not back up legacy json");
}
}
#[derive(Deserialize)]
struct LegacyTrack {
#[serde(default)]
path: Option<String>,
#[serde(default)]
id: Option<TrackId>,
#[serde(default)]
cover: Option<String>,
#[serde(default)]
album_id: String,
#[serde(default)]
title: String,
#[serde(default)]
artist: String,
#[serde(default)]
album: String,
#[serde(default)]
duration: u64,
#[serde(default)]
khz: u32,
#[serde(default)]
bitrate: u16,
#[serde(default)]
track_number: Option<u32>,
#[serde(default)]
disc_number: Option<u32>,
#[serde(default)]
musicbrainz_release_id: Option<String>,
#[serde(default)]
musicbrainz_recording_id: Option<String>,
#[serde(default)]
musicbrainz_track_id: Option<String>,
#[serde(default)]
playlist_item_id: Option<String>,
#[serde(default)]
artists: Vec<String>,
}
#[derive(Deserialize)]
struct LegacyAlbum {
id: String,
#[serde(default)]
title: String,
#[serde(default)]
artist: String,
#[serde(default)]
genre: String,
#[serde(default)]
year: i64,
#[serde(default)]
cover_path: Option<String>,
#[serde(default)]
manual_cover: bool,
}
#[derive(Deserialize, Default)]
struct LegacyLibrary {
#[serde(default)]
tracks: Vec<LegacyTrack>,
#[serde(default)]
albums: Vec<LegacyAlbum>,
#[serde(default)]
jellyfin_tracks: Vec<LegacyTrack>,
#[serde(default)]
jellyfin_albums: Vec<LegacyAlbum>,
#[serde(default)]
last_yt_sync_at: Option<i64>,
#[serde(default)]
last_yt_playlists_sync_at: Option<i64>,
#[serde(default)]
server_artist_images: HashMap<String, String>,
#[serde(default)]
local_artist_images: HashMap<String, String>,
#[serde(default)]
custom_artist_images: HashMap<String, String>,
}
#[derive(Deserialize)]
struct LegacyPlaylist {
id: String,
#[serde(default)]
name: String,
#[serde(default)]
tracks: Vec<String>,
#[serde(default)]
cover_path: Option<String>,
}
#[derive(Deserialize)]
struct LegacyJellyfinPlaylist {
id: String,
#[serde(default)]
name: String,
#[serde(default)]
tracks: Vec<String>,
#[serde(default)]
image_tag: Option<String>,
#[serde(default)]
cover_path: Option<String>,
}
#[derive(Deserialize)]
struct LegacyFolder {
id: String,
#[serde(default)]
name: String,
#[serde(default)]
playlist_ids: Vec<String>,
}
#[derive(Deserialize, Default)]
struct LegacyPlaylists {
#[serde(default)]
playlists: Vec<LegacyPlaylist>,
#[serde(default)]
jellyfin_playlists: Vec<LegacyJellyfinPlaylist>,
#[serde(default)]
folders: Vec<LegacyFolder>,
}
#[derive(Deserialize, Default)]
struct LegacyFavorites {
#[serde(default)]
local_favorites: Vec<String>,
#[serde(default)]
jellyfin_favorites: Vec<String>,
}
fn default_version() -> u32 {
1
}
#[derive(Deserialize, Default)]
struct LegacyQueue {
#[serde(default = "default_version")]
version: u32,
#[serde(default)]
queue: Vec<LegacyTrack>,
#[serde(default)]
current_queue_index: i64,
#[serde(default)]
progress_secs: i64,
#[serde(default)]
shuffle_order: Vec<u32>,
#[serde(default)]
shuffle_enabled: bool,
}
#[cfg(test)]
mod eol_reconcile_tests {
use super::*;
use sha2::{Digest, Sha384};
#[tokio::test]
async fn crlf_db_reconciles_but_real_edit_still_fails() {
let dir = std::env::temp_dir().join(format!("kopuz-eol-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let db = dir.join("t.db");
for ext in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(with_ext(&db, ext));
}
let pool = open_pool(&db).await.unwrap();
run_migrations(&pool).await.unwrap();
for m in MIGRATOR.iter() {
let crlf = m.sql.replace("\r\n", "\n").replace('\n', "\r\n");
sqlx::query("UPDATE _sqlx_migrations SET checksum = ?1 WHERE version = ?2")
.bind(Sha384::digest(crlf.as_bytes()).to_vec())
.bind(m.version)
.execute(&pool)
.await
.unwrap();
}
run_migrations(&pool)
.await
.expect("CRLF-only checksum mismatch should reconcile");
let stored: Vec<(i64, Vec<u8>)> =
sqlx::query_as("SELECT version, checksum FROM _sqlx_migrations")
.fetch_all(&pool)
.await
.unwrap();
for m in MIGRATOR.iter() {
let ck = stored.iter().find(|(v, _)| *v == m.version).map(|(_, c)| c);
assert_eq!(ck.unwrap().as_slice(), m.checksum.as_ref());
}
sqlx::query(
"UPDATE _sqlx_migrations SET checksum = ?1 \
WHERE version = (SELECT MIN(version) FROM _sqlx_migrations)",
)
.bind(vec![0u8; 48])
.execute(&pool)
.await
.unwrap();
assert!(
run_migrations(&pool).await.is_err(),
"a real migration edit must still be rejected"
);
drop(pool);
let _ = std::fs::remove_dir_all(&dir);
}
}