feat(library): add scoped browse projection foundation

This commit is contained in:
cucadmuh
2026-07-17 03:48:16 +03:00
parent ec9b5e8c63
commit 587fc1a70a
31 changed files with 1243 additions and 22 deletions
@@ -0,0 +1,33 @@
-- Materialized per-library album rows for candidate-first scoped browse. The
-- track table remains authoritative; this table avoids GROUP BY track on every
-- All Albums page request.
CREATE TABLE IF NOT EXISTS album_browse_projection (
server_id TEXT NOT NULL,
library_id TEXT NOT NULL,
album_id TEXT NOT NULL,
identity_key TEXT,
name TEXT NOT NULL,
artist TEXT,
artist_id TEXT,
song_count INTEGER NOT NULL,
duration_sec INTEGER NOT NULL,
year INTEGER,
genre TEXT,
cover_art_id TEXT,
starred_at INTEGER,
synced_at INTEGER NOT NULL,
representative_track_id TEXT NOT NULL,
PRIMARY KEY (server_id, library_id, album_id)
);
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_name
ON album_browse_projection(server_id, library_id, name COLLATE NOCASE, album_id);
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_artist
ON album_browse_projection(server_id, library_id, artist COLLATE NOCASE, name COLLATE NOCASE, album_id);
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_artist_year
ON album_browse_projection(server_id, library_id, artist COLLATE NOCASE, year, name COLLATE NOCASE, album_id);
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_identity
ON album_browse_projection(server_id, library_id, identity_key);
@@ -0,0 +1,409 @@
//! Materialized browse rows maintained alongside track ingest.
//!
//! The `track` catalog remains authoritative. These compact rows avoid grouping
//! every track in a selected library before the first All Albums page can render.
use std::collections::HashSet;
use rusqlite::{params, Connection, OptionalExtension, Transaction};
use tauri::{AppHandle, Emitter};
use crate::repos::TrackRow;
use crate::store::LibraryStore;
type AlbumScope = (String, String, String);
pub const MIGRATION_ID: &str = "scope_browse_album_projection_v1";
const BACKFILL_BATCH_SIZE: i64 = 10_000;
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScopeBrowseProjectionInspectDto {
pub needed: bool,
pub total_tracks: u64,
pub done_tracks: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScopeBrowseProjectionProgressEvent {
pub done: u64,
pub total: u64,
}
fn add_scope(
scopes: &mut HashSet<AlbumScope>,
server_id: &str,
library_id: Option<String>,
album_id: Option<String>,
) {
let Some(album_id) = album_id.filter(|id| !id.is_empty()) else {
return;
};
scopes.insert((server_id.to_string(), library_id.unwrap_or_default(), album_id));
}
/// Capture old and incoming album owners before a track batch changes them.
pub(crate) fn collect_affected_album_scopes(
tx: &Transaction<'_>,
rows: &[TrackRow],
) -> rusqlite::Result<HashSet<AlbumScope>> {
let mut scopes = HashSet::new();
let mut previous = tx.prepare_cached(
"SELECT library_id, album_id FROM track WHERE server_id = ?1 AND id = ?2",
)?;
for row in rows {
if let Some((library_id, album_id)) = previous
.query_row(params![row.server_id, row.id], |r| Ok((r.get(0)?, r.get(1)?)))
.optional()?
{
add_scope(&mut scopes, &row.server_id, library_id, album_id);
}
add_scope(
&mut scopes,
&row.server_id,
row.library_id.clone(),
row.album_id.clone(),
);
}
Ok(scopes)
}
/// Recompute only albums affected by a single track ingest transaction.
pub(crate) fn refresh_album_scopes(
tx: &Transaction<'_>,
scopes: HashSet<AlbumScope>,
) -> rusqlite::Result<()> {
let mut delete = tx.prepare_cached(
"DELETE FROM album_browse_projection \
WHERE server_id = ?1 AND library_id = ?2 AND album_id = ?3",
)?;
let mut insert = tx.prepare_cached(
"INSERT INTO album_browse_projection ( \
server_id, library_id, album_id, name, artist, artist_id, song_count, \
duration_sec, year, genre, cover_art_id, starred_at, synced_at, representative_track_id \
) \
SELECT t.server_id, COALESCE(t.library_id, ''), t.album_id, MAX(t.album), \
MAX(COALESCE(NULLIF(TRIM(t.album_artist), ''), t.artist)), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at), MIN(t.id) \
FROM track t \
WHERE t.server_id = ?1 AND COALESCE(t.library_id, '') = ?2 AND t.album_id = ?3 \
AND t.deleted = 0 \
GROUP BY t.server_id, COALESCE(t.library_id, ''), t.album_id",
)?;
let mut update_identity = tx.prepare_cached(
"UPDATE album_browse_projection SET identity_key = ?4 \
WHERE server_id = ?1 AND library_id = ?2 AND album_id = ?3",
)?;
let mut identity_source = tx.prepare_cached(
"SELECT name, artist FROM album_browse_projection \
WHERE server_id = ?1 AND library_id = ?2 AND album_id = ?3",
)?;
for (server_id, library_id, album_id) in scopes {
delete.execute(params![server_id, library_id, album_id])?;
insert.execute(params![server_id, library_id, album_id])?;
let source = identity_source
.query_row(params![server_id, library_id, album_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
})
.optional()?;
if let Some((name, artist)) = source {
let identity_key = crate::identity::build_track_cluster_keys(
artist.as_deref(),
"",
&name,
artist.as_deref(),
)
.album_key;
update_identity.execute(params![server_id, library_id, album_id, identity_key])?;
}
}
Ok(())
}
/// Full resync can tombstone arbitrary old rows, so rebuild one server's compact
/// projection after its orphan sweep instead of leaving deleted albums visible.
pub(crate) fn rebuild_server(
tx: &Transaction<'_>,
server_id: &str,
) -> rusqlite::Result<()> {
tx.execute(
"DELETE FROM album_browse_projection WHERE server_id = ?1",
params![server_id],
)?;
tx.execute(
"INSERT INTO album_browse_projection ( \
server_id, library_id, album_id, name, artist, artist_id, song_count, \
duration_sec, year, genre, cover_art_id, starred_at, synced_at, representative_track_id \
) \
SELECT t.server_id, COALESCE(t.library_id, ''), t.album_id, MAX(t.album), \
MAX(COALESCE(NULLIF(TRIM(t.album_artist), ''), t.artist)), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at), MIN(t.id) \
FROM track t \
WHERE t.server_id = ?1 AND t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \
GROUP BY t.server_id, COALESCE(t.library_id, ''), t.album_id",
params![server_id],
)?;
let mut stmt = tx.prepare(
"SELECT library_id, album_id, name, artist FROM album_browse_projection WHERE server_id = ?1",
)?;
let rows = stmt
.query_map(params![server_id], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, Option<String>>(3)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
drop(stmt);
let mut update = tx.prepare_cached(
"UPDATE album_browse_projection SET identity_key = ?4 \
WHERE server_id = ?1 AND library_id = ?2 AND album_id = ?3",
)?;
for (library_id, album_id, name, artist) in rows {
let identity_key = crate::identity::build_track_cluster_keys(
artist.as_deref(),
"",
&name,
artist.as_deref(),
)
.album_key;
update.execute(params![server_id, library_id, album_id, identity_key])?;
}
Ok(())
}
fn migration_completed(conn: &Connection) -> rusqlite::Result<bool> {
let completed: Option<Option<i64>> = conn
.query_row(
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
params![MIGRATION_ID],
|r| r.get(0),
)
.optional()?;
Ok(completed.flatten().is_some())
}
fn cursor_rowid(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT cursor_rowid FROM library_data_migration WHERE id = ?1",
params![MIGRATION_ID],
|r| r.get(0),
)
.optional()
.map(|cursor| cursor.unwrap_or(0))
}
pub fn inspect(store: &LibraryStore) -> Result<ScopeBrowseProjectionInspectDto, String> {
store
.with_read_conn(|conn| {
let total: i64 = conn.query_row("SELECT COUNT(*) FROM track WHERE deleted = 0", [], |r| r.get(0))?;
if total == 0 || migration_completed(conn)? {
return Ok(ScopeBrowseProjectionInspectDto {
needed: false,
total_tracks: total.max(0) as u64,
done_tracks: total.max(0) as u64,
});
}
let migration_started: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM library_data_migration WHERE id = ?1)",
params![MIGRATION_ID],
|r| r.get(0),
)?;
let has_projection: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM album_browse_projection)",
[],
|r| r.get(0),
)?;
if !migration_started && has_projection {
return Ok(ScopeBrowseProjectionInspectDto {
needed: false,
total_tracks: total.max(0) as u64,
done_tracks: total.max(0) as u64,
});
}
let cursor = cursor_rowid(conn)?;
let done: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
params![cursor],
|r| r.get(0),
)?;
Ok(ScopeBrowseProjectionInspectDto {
needed: true,
total_tracks: total.max(0) as u64,
done_tracks: done.max(0) as u64,
})
})
.map_err(|error| error.to_string())
}
pub fn is_ready(store: &LibraryStore) -> Result<bool, String> {
store
.with_read_conn(|conn| {
if migration_completed(conn)? {
return Ok(true);
}
// Fresh installs have no legacy catalog to backfill: sync maintains
// projection rows incrementally before the first browse request.
let migration_started: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM library_data_migration WHERE id = ?1)",
params![MIGRATION_ID],
|r| r.get(0),
)?;
if migration_started {
return Ok(false);
}
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM album_browse_projection)",
[],
|r| r.get(0),
)
})
.map_err(|error| error.to_string())
}
pub fn run_backfill(store: &LibraryStore, app: &AppHandle) -> Result<(), String> {
run_backfill_impl(store, Some(app))
}
fn run_backfill_impl(store: &LibraryStore, app: Option<&AppHandle>) -> Result<(), String> {
let inspect_result = inspect(store)?;
if !inspect_result.needed {
return Ok(());
}
loop {
let (done, finished) = store.with_conn_mut("browse_projection.backfill", |conn| {
if migration_completed(conn)? {
return Ok((inspect_result.total_tracks, true));
}
conn.execute(
"INSERT INTO library_data_migration (id, cursor_rowid, started_at) \
VALUES (?1, 0, strftime('%s','now')) \
ON CONFLICT(id) DO NOTHING",
params![MIGRATION_ID],
)?;
let cursor = cursor_rowid(conn)?;
let tx = conn.transaction()?;
let rows = {
let mut stmt = tx.prepare(
"SELECT rowid, server_id, COALESCE(library_id, ''), album_id \
FROM track WHERE deleted = 0 AND rowid > ?1 \
ORDER BY rowid LIMIT ?2",
)?;
let rows = stmt.query_map(params![cursor, BACKFILL_BATCH_SIZE], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, Option<String>>(3)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
if let Some(last_rowid) = rows.last().map(|row| row.0) {
let mut scopes = HashSet::new();
for (_, server_id, library_id, album_id) in rows {
add_scope(&mut scopes, &server_id, Some(library_id), album_id);
}
refresh_album_scopes(&tx, scopes)?;
tx.execute(
"UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1",
params![MIGRATION_ID, last_rowid],
)?;
tx.commit()?;
let done: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
params![last_rowid],
|r| r.get(0),
)?;
Ok((done.max(0) as u64, false))
} else {
tx.execute(
"UPDATE library_data_migration SET completed_at = strftime('%s','now') WHERE id = ?1",
params![MIGRATION_ID],
)?;
tx.commit()?;
Ok((inspect_result.total_tracks, true))
}
})?;
if let Some(app) = app {
app.emit(
"scope_browse_projection:progress",
ScopeBrowseProjectionProgressEvent { done, total: inspect_result.total_tracks },
)
.map_err(|error| error.to_string())?;
}
if finished {
return Ok(());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
fn track(id: &str, album_id: &str, album: &str, library_id: &str) -> TrackRow {
TrackRow {
server_id: "s1".into(), id: id.into(), title: id.into(), title_sort: None,
artist: Some("Artist".into()), artist_id: Some("artist".into()), album: album.into(),
album_id: Some(album_id.into()), album_artist: Some("Artist".into()), duration_sec: 120,
track_number: None, disc_number: None, year: Some(2024), genre: None, suffix: None,
bit_rate: None, size_bytes: None, cover_art_id: None, starred_at: None, user_rating: None,
play_count: None, played_at: None, server_path: None, library_id: Some(library_id.into()),
isrc: None, mbid_recording: None, bpm: None, replay_gain_track_db: None,
replay_gain_album_db: None, replay_gain_peak: None, content_hash: None,
server_updated_at: None, server_created_at: None, deleted: false, synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn ingest_refreshes_only_affected_album_projection() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("t1", "a1", "Album One", "lib")])
.unwrap();
let name: String = store.with_read_conn(|conn| conn.query_row(
"SELECT name FROM album_browse_projection WHERE server_id = 's1' AND library_id = 'lib' AND album_id = 'a1'",
[], |row| row.get(0),
)).unwrap();
assert_eq!(name, "Album One");
TrackRepository::new(&store)
.upsert_batch(&[track("t1", "a1", "Album Renamed", "lib")])
.unwrap();
let name: String = store.with_read_conn(|conn| conn.query_row(
"SELECT name FROM album_browse_projection WHERE server_id = 's1' AND library_id = 'lib' AND album_id = 'a1'",
[], |row| row.get(0),
)).unwrap();
assert_eq!(name, "Album Renamed");
}
#[test]
fn backfill_processes_tracks_without_album_ids_before_advancing_cursor() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("no-album", "", "Ignored", "lib"),
track("t1", "a1", "Album One", "lib"),
])
.unwrap();
store.with_conn_mut("test.clear_projection_marker", |conn| {
conn.execute("DELETE FROM album_browse_projection", [])?;
conn.execute("DELETE FROM library_data_migration WHERE id = ?1", params![MIGRATION_ID])?;
Ok(())
}).unwrap();
run_backfill_impl(&store, None).unwrap();
assert!(is_ready(&store).unwrap());
let count: i64 = store.with_read_conn(|conn| conn.query_row(
"SELECT COUNT(*) FROM album_browse_projection WHERE album_id = 'a1'", [], |row| row.get(0),
)).unwrap();
assert_eq!(count, 1);
}
}
@@ -26,7 +26,8 @@ use crate::dto::{
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse,
LibraryMainstageAlbumsRequest, LibraryMainstageAlbumsResponse,
LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest,
LibraryScopeArtistDetailResponse, LibraryScopeListRequest, LibraryScopeSearchRequest,
LibraryScopeArtistDetailResponse, LibraryScopeBrowseRequest, LibraryScopeBrowseResponse,
LibraryScopeListRequest, LibraryScopeSearchRequest,
LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
@@ -736,6 +737,32 @@ pub async fn library_scope_list_albums(
library_spawn_blocking(move || scope_merge::list_albums(&store, &request)).await
}
/// Candidate-first indexed browse for ordinary Albums / Tracks / Artists pages.
#[tauri::command]
pub async fn library_scope_browse(
runtime: State<'_, LibraryRuntime>,
request: LibraryScopeBrowseRequest,
) -> Result<LibraryScopeBrowseResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::scope_browse::browse(&store, &request)).await
}
#[tauri::command]
pub fn library_scope_browse_projection_inspect(
runtime: State<'_, LibraryRuntime>,
) -> Result<crate::browse_projection::ScopeBrowseProjectionInspectDto, String> {
crate::browse_projection::inspect(&runtime.store)
}
#[tauri::command]
pub async fn library_scope_browse_projection_run(
app: tauri::AppHandle,
runtime: State<'_, LibraryRuntime>,
) -> Result<(), String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::browse_projection::run_backfill(&store, &app)).await
}
// NOT specta-collected: returns LibraryAlbumDto carrying raw_json: Value.
#[tauri::command]
pub async fn library_scope_list_mainstage_albums(
@@ -720,6 +720,41 @@ pub struct LibraryScopePair {
pub library_id: String,
}
/// Entity surface served by the cursor-based scoped browse engine.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum LibraryScopeBrowseEntity {
Album,
Artist,
Track,
}
/// Indexed catalogue browse request. FTS and arbitrary compound filters remain
/// on `library_advanced_search`; this contract powers ordinary entity pages.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeBrowseRequest {
pub entity: LibraryScopeBrowseEntity,
pub scopes: Vec<LibraryScopePair>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
pub limit: u32,
#[serde(default)]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeBrowseResponse {
pub albums: Vec<LibraryAlbumDto>,
pub artists: Vec<LibraryArtistDto>,
pub tracks: Vec<LibraryTrackDto>,
#[serde(default)]
pub next_cursor: Option<String>,
pub has_more: bool,
pub source: String,
}
/// Derive ordered `(server_id, library_id)` pairs from request fields.
/// List order is merge priority (index 0 wins). Empty = all libraries on the server.
pub(crate) fn ordered_library_scope_pairs(
@@ -11,6 +11,8 @@ pub(crate) mod bulk_ingest;
pub mod advanced_search;
pub mod album_compilation_filter;
pub mod browse_support;
pub mod scope_browse;
pub mod browse_projection;
mod advanced_search_mood;
pub mod analysis_backfill;
pub mod analysis_backfill_policy;
@@ -118,6 +118,7 @@ impl<'a> TrackRepository<'a> {
};
let (_, timing) = self.store.with_conn_mut_timed("track.upsert_initial_ingest", |conn| {
let tx = conn.transaction()?;
let affected_album_scopes = crate::browse_projection::collect_affected_album_scopes(&tx, rows)?;
let mut upsert = tx.prepare_cached(sql)?;
for r in rows {
if let Some(gen) = resync_gen {
@@ -203,6 +204,7 @@ impl<'a> TrackRepository<'a> {
sync_track_genre_row(&tx, r)?;
}
drop(upsert);
crate::browse_projection::refresh_album_scopes(&tx, affected_album_scopes)?;
tx.commit()?;
Ok(())
})?;
@@ -224,7 +226,8 @@ impl<'a> TrackRepository<'a> {
pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> {
let now = now_unix_ms();
let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| {
c.execute(
let tx = c.transaction()?;
tx.execute(
"DELETE FROM track_genre \
WHERE server_id = ?1 AND track_id IN ( \
SELECT id FROM track \
@@ -232,11 +235,14 @@ impl<'a> TrackRepository<'a> {
)",
params![server_id, resync_gen],
)?;
c.execute(
let changed = tx.execute(
"UPDATE track SET deleted = 1, synced_at = ?3 \
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
params![server_id, resync_gen, now],
)
)?;
crate::browse_projection::rebuild_server(&tx, server_id)?;
tx.commit()?;
Ok(changed)
})?;
Ok(changed as u32)
}
@@ -504,6 +510,7 @@ impl<'a> TrackRepository<'a> {
}
self.store.with_conn_mut("track.upsert_batch_remap", |conn| {
let tx = conn.transaction()?;
let affected_album_scopes = crate::browse_projection::collect_affected_album_scopes(&tx, rows)?;
let mut remapped: Vec<RemapEntry> = Vec::new();
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
let mut remap_lookup = if unstable_track_ids {
@@ -600,6 +607,7 @@ impl<'a> TrackRepository<'a> {
drop(upsert);
drop(remap_lookup);
crate::browse_projection::refresh_album_scopes(&tx, affected_album_scopes)?;
tx.commit()?;
Ok(RemapStats { remapped })
@@ -0,0 +1,468 @@
//! Candidate-first, cursor-paginated browse over ordered library scopes.
//!
//! Advanced Search remains responsible for FTS and arbitrary compound filters.
//! This module serves ordinary catalogue pages from materialized/indexed rows.
use std::cmp::Ordering;
use rusqlite::{params_from_iter, types::Value as SqlValue};
use serde::{Deserialize, Serialize};
use crate::dto::{
LibraryAlbumDto, LibraryScopeBrowseEntity, LibraryScopeBrowseRequest,
LibraryScopeBrowseResponse, LibraryScopePair, LibrarySortClause,
};
use crate::store::LibraryStore;
const CANDIDATE_PAGE_SIZE: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum AlbumSort {
Name,
Artist,
ArtistYear,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AlbumCursor {
scope_key: String,
sort: AlbumSort,
positions: Vec<Option<AlbumCursorPosition>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AlbumCursorPosition {
name: String,
artist: String,
year: i64,
album_id: String,
}
#[derive(Debug, Clone)]
struct AlbumCandidate {
priority: usize,
server_id: String,
library_id: String,
album_id: String,
identity_key: Option<String>,
name: String,
artist: Option<String>,
artist_id: Option<String>,
song_count: i64,
duration_sec: i64,
year: Option<i64>,
genre: Option<String>,
cover_art_id: Option<String>,
starred_at: Option<i64>,
synced_at: i64,
}
fn album_sort(sort: &[LibrarySortClause]) -> Result<AlbumSort, String> {
let fields: Vec<&str> = sort.iter().map(|clause| clause.field.as_str()).collect();
match fields.as_slice() {
[] | ["name"] | ["name", "artist"] => Ok(AlbumSort::Name),
["artist"] | ["artist", "name"] => Ok(AlbumSort::Artist),
["artist", "year"] | ["artist", "year", "name"] => Ok(AlbumSort::ArtistYear),
_ => Err("unsupported scope browse album sort".into()),
}
}
fn order_sql(sort: AlbumSort) -> &'static str {
match sort {
AlbumSort::Name => {
"name COLLATE NOCASE ASC, COALESCE(artist, '') COLLATE NOCASE ASC, album_id ASC"
}
AlbumSort::Artist => {
"COALESCE(artist, '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, album_id ASC"
}
AlbumSort::ArtistYear => {
"COALESCE(artist, '') COLLATE NOCASE ASC, COALESCE(year, 0) ASC, name COLLATE NOCASE ASC, album_id ASC"
}
}
}
fn candidate_cmp(sort: AlbumSort, a: &AlbumCandidate, b: &AlbumCandidate) -> Ordering {
let fold = |value: &str| value.to_lowercase();
let by_name = || fold(&a.name).cmp(&fold(&b.name));
let by_artist = || fold(a.artist.as_deref().unwrap_or("")).cmp(&fold(b.artist.as_deref().unwrap_or("")));
let order = match sort {
AlbumSort::Name => by_name().then_with(by_artist),
AlbumSort::Artist => by_artist().then_with(by_name),
AlbumSort::ArtistYear => by_artist()
.then_with(|| a.year.unwrap_or(0).cmp(&b.year.unwrap_or(0)))
.then_with(by_name),
};
order
.then_with(|| a.priority.cmp(&b.priority))
.then_with(|| a.server_id.cmp(&b.server_id))
.then_with(|| a.library_id.cmp(&b.library_id))
.then_with(|| a.album_id.cmp(&b.album_id))
}
fn candidate_to_dto(candidate: AlbumCandidate) -> LibraryAlbumDto {
LibraryAlbumDto {
server_id: candidate.server_id,
id: candidate.album_id,
name: candidate.name,
artist: candidate.artist,
artist_id: candidate.artist_id,
song_count: Some(candidate.song_count),
duration_sec: Some(candidate.duration_sec),
year: candidate.year,
genre: candidate.genre,
cover_art_id: candidate.cover_art_id,
starred_at: candidate.starred_at,
synced_at: candidate.synced_at,
raw_json: serde_json::Value::Null,
}
}
fn scope_key(scopes: &[LibraryScopePair]) -> String {
scopes
.iter()
.map(|scope| format!("{}\u{1f}{}", scope.server_id, scope.library_id))
.collect::<Vec<_>>()
.join("\u{1e}")
}
fn parse_cursor(
cursor: Option<&str>,
scopes: &[LibraryScopePair],
sort: AlbumSort,
) -> Result<Option<AlbumCursor>, String> {
let Some(raw) = cursor else {
return Ok(None);
};
let parsed: AlbumCursor = serde_json::from_str(raw).map_err(|_| "invalid scope browse cursor")?;
if parsed.scope_key != scope_key(scopes) || parsed.sort != sort || parsed.positions.len() != scopes.len() {
return Err("scope browse cursor does not match the current scope or sort".into());
}
Ok(Some(parsed))
}
fn cursor_position(candidate: &AlbumCandidate) -> AlbumCursorPosition {
AlbumCursorPosition {
name: candidate.name.clone(),
artist: candidate.artist.clone().unwrap_or_default(),
year: candidate.year.unwrap_or(0),
album_id: candidate.album_id.clone(),
}
}
fn seek_sql(sort: AlbumSort, position: Option<&AlbumCursorPosition>) -> (String, Vec<SqlValue>) {
let Some(position) = position else {
return (String::new(), Vec::new());
};
match sort {
AlbumSort::Name => (
"AND (name COLLATE NOCASE > ? OR (name COLLATE NOCASE = ? AND (COALESCE(artist, '') COLLATE NOCASE > ? OR (COALESCE(artist, '') COLLATE NOCASE = ? AND album_id > ?))))".into(),
vec![
SqlValue::Text(position.name.clone()),
SqlValue::Text(position.name.clone()),
SqlValue::Text(position.artist.clone()),
SqlValue::Text(position.artist.clone()),
SqlValue::Text(position.album_id.clone()),
],
),
AlbumSort::Artist => (
"AND (COALESCE(artist, '') COLLATE NOCASE > ? OR (COALESCE(artist, '') COLLATE NOCASE = ? AND (name COLLATE NOCASE > ? OR (name COLLATE NOCASE = ? AND album_id > ?))))".into(),
vec![
SqlValue::Text(position.artist.clone()),
SqlValue::Text(position.artist.clone()),
SqlValue::Text(position.name.clone()),
SqlValue::Text(position.name.clone()),
SqlValue::Text(position.album_id.clone()),
],
),
AlbumSort::ArtistYear => (
"AND (COALESCE(artist, '') COLLATE NOCASE > ? OR (COALESCE(artist, '') COLLATE NOCASE = ? AND (COALESCE(year, 0) > ? OR (COALESCE(year, 0) = ? AND (name COLLATE NOCASE > ? OR (name COLLATE NOCASE = ? AND album_id > ?))))))".into(),
vec![
SqlValue::Text(position.artist.clone()),
SqlValue::Text(position.artist.clone()),
SqlValue::Integer(position.year),
SqlValue::Integer(position.year),
SqlValue::Text(position.name.clone()),
SqlValue::Text(position.name.clone()),
SqlValue::Text(position.album_id.clone()),
],
),
}
}
fn query_scope_candidates(
store: &LibraryStore,
pair: &LibraryScopePair,
priority: usize,
sort: AlbumSort,
cursor_position: Option<&AlbumCursorPosition>,
limit: usize,
) -> Result<Vec<AlbumCandidate>, String> {
let (seek, mut binds) = seek_sql(sort, cursor_position);
let sql = format!(
"SELECT server_id, library_id, album_id, identity_key, name, artist, artist_id, song_count, \
duration_sec, year, genre, cover_art_id, starred_at, synced_at \
FROM album_browse_projection \
WHERE server_id = ? AND library_id = ? {seek} \
ORDER BY {} LIMIT ?",
order_sql(sort),
);
binds.splice(
0..0,
[
SqlValue::Text(pair.server_id.clone()),
SqlValue::Text(pair.library_id.clone()),
],
);
binds.push(SqlValue::Integer(limit as i64));
store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(
params_from_iter(binds.iter()),
|row| {
Ok(AlbumCandidate {
priority,
server_id: row.get(0)?,
library_id: row.get(1)?,
album_id: row.get(2)?,
identity_key: row.get(3)?,
name: row.get(4)?,
artist: row.get(5)?,
artist_id: row.get(6)?,
song_count: row.get(7)?,
duration_sec: row.get(8)?,
year: row.get(9)?,
genre: row.get(10)?,
cover_art_id: row.get(11)?,
starred_at: row.get(12)?,
synced_at: row.get(13)?,
})
},
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|error| error.to_string())
}
fn exists_in_higher_priority_scope(
store: &LibraryStore,
scopes: &[LibraryScopePair],
priority: usize,
identity_key: &str,
) -> Result<bool, String> {
if priority == 0 || identity_key.is_empty() {
return Ok(false);
}
let clauses = (0..priority)
.map(|_| "(server_id = ? AND library_id = ?)")
.collect::<Vec<_>>()
.join(" OR ");
let sql = format!(
"SELECT 1 FROM album_browse_projection \
WHERE identity_key = ? AND ({clauses}) LIMIT 1",
);
let mut binds = vec![SqlValue::Text(identity_key.to_string())];
for scope in scopes.iter().take(priority) {
binds.push(SqlValue::Text(scope.server_id.clone()));
binds.push(SqlValue::Text(scope.library_id.clone()));
}
store
.with_read_conn(|conn| {
let present = conn
.query_row(&sql, params_from_iter(binds.iter()), |_| Ok(()))
.is_ok();
Ok(present)
})
.map_err(|error| error.to_string())
}
fn browse_albums(
store: &LibraryStore,
request: &LibraryScopeBrowseRequest,
) -> Result<LibraryScopeBrowseResponse, String> {
let sort = album_sort(&request.sort)?;
let cursor = parse_cursor(request.cursor.as_deref(), &request.scopes, sort)?;
let limit = request.limit.clamp(1, 200) as usize;
let candidate_limit = CANDIDATE_PAGE_SIZE.max(limit.saturating_add(1));
let mut candidates = Vec::with_capacity(request.scopes.len());
let mut stream_exhausted = Vec::with_capacity(request.scopes.len());
for (priority, scope) in request.scopes.iter().enumerate() {
let stream = query_scope_candidates(
store,
scope,
priority,
sort,
cursor.as_ref().and_then(|cursor| cursor.positions.get(priority)).and_then(Option::as_ref),
candidate_limit,
)?;
stream_exhausted.push(stream.len() < candidate_limit);
candidates.push(stream);
}
let mut albums = Vec::with_capacity(limit.saturating_add(1));
let mut offsets = vec![0usize; candidates.len()];
let mut positions = cursor
.map(|cursor| cursor.positions)
.unwrap_or_else(|| vec![None; request.scopes.len()]);
while albums.len() < limit {
for scope_index in 0..candidates.len() {
if offsets[scope_index] < candidates[scope_index].len() || stream_exhausted[scope_index] {
continue;
}
let stream = query_scope_candidates(
store,
&request.scopes[scope_index],
scope_index,
sort,
positions[scope_index].as_ref(),
candidate_limit,
)?;
stream_exhausted[scope_index] = stream.len() < candidate_limit;
candidates[scope_index] = stream;
offsets[scope_index] = 0;
}
let next_scope = candidates
.iter()
.enumerate()
.filter(|(index, stream)| offsets[*index] < stream.len())
.min_by(|(left_index, left_stream), (right_index, right_stream)| {
candidate_cmp(
sort,
&left_stream[offsets[*left_index]],
&right_stream[offsets[*right_index]],
)
})
.map(|(index, _)| index);
let Some(scope_index) = next_scope else { break; };
let candidate = &candidates[scope_index][offsets[scope_index]];
offsets[scope_index] += 1;
positions[scope_index] = Some(cursor_position(candidate));
if let Some(identity_key) = candidate.identity_key.as_deref() {
if exists_in_higher_priority_scope(store, &request.scopes, candidate.priority, identity_key)? {
continue;
}
}
albums.push(candidate_to_dto(candidate.clone()));
}
let has_more = candidates
.iter()
.enumerate()
.any(|(index, stream)| offsets[index] < stream.len() || !stream_exhausted[index]);
let next_cursor = has_more.then(|| {
serde_json::to_string(&AlbumCursor {
scope_key: scope_key(&request.scopes),
sort,
positions,
})
.expect("scope browse cursor serializes")
});
Ok(LibraryScopeBrowseResponse {
albums,
artists: Vec::new(),
tracks: Vec::new(),
next_cursor,
has_more,
source: "local".into(),
})
}
pub fn browse(
store: &LibraryStore,
request: &LibraryScopeBrowseRequest,
) -> Result<LibraryScopeBrowseResponse, String> {
if request.scopes.is_empty() {
return Err("scope browse requires at least one library scope".into());
}
if !crate::browse_projection::is_ready(store)? {
return Err("scope browse projection is not ready".into());
}
match request.entity {
LibraryScopeBrowseEntity::Album => browse_albums(store, request),
_ => Err("scope browse entity is not implemented yet".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request(scopes: Vec<LibraryScopePair>, limit: u32, cursor: Option<String>) -> LibraryScopeBrowseRequest {
LibraryScopeBrowseRequest {
entity: LibraryScopeBrowseEntity::Album,
scopes,
sort: vec![
LibrarySortClause { field: "name".into(), dir: crate::dto::SortDir::Asc },
LibrarySortClause { field: "artist".into(), dir: crate::dto::SortDir::Asc },
],
limit,
cursor,
}
}
fn insert_projection(
store: &LibraryStore,
server_id: &str,
library_id: &str,
album_id: &str,
name: &str,
identity_key: Option<&str>,
) {
store.with_conn_mut("test.scope_browse.seed", |conn| {
conn.execute(
"INSERT INTO album_browse_projection ( \
server_id, library_id, album_id, identity_key, name, artist, artist_id, song_count, \
duration_sec, year, genre, cover_art_id, starred_at, synced_at, representative_track_id \
) VALUES (?1, ?2, ?3, ?4, ?5, 'Artist', NULL, 1, 1, 2024, NULL, NULL, NULL, 1, ?3)",
rusqlite::params![server_id, library_id, album_id, identity_key, name],
)?;
conn.execute(
"INSERT INTO library_data_migration (id, cursor_rowid, started_at, completed_at) \
VALUES ('scope_browse_album_projection_v1', 0, 1, 1) \
ON CONFLICT(id) DO UPDATE SET completed_at = 1",
[],
)?;
Ok(())
}).unwrap();
}
#[test]
fn priority_scope_wins_even_when_its_duplicate_sorts_later() {
let store = LibraryStore::open_in_memory();
insert_projection(&store, "high", "lib", "high-dup", "Zulu", Some("same"));
insert_projection(&store, "low", "lib", "low-dup", "Alpha", Some("same"));
insert_projection(&store, "low", "lib", "low-unique", "Bravo", Some("other"));
let response = browse(&store, &request(vec![
LibraryScopePair { server_id: "high".into(), library_id: "lib".into() },
LibraryScopePair { server_id: "low".into(), library_id: "lib".into() },
], 10, None)).unwrap();
assert_eq!(
response.albums.iter().map(|album| album.id.as_str()).collect::<Vec<_>>(),
vec!["low-unique", "high-dup"],
);
}
#[test]
fn cursor_keeps_each_scope_position_without_skipping_tied_global_order() {
let store = LibraryStore::open_in_memory();
insert_projection(&store, "a", "lib", "a-bravo", "Bravo", Some("a-bravo"));
insert_projection(&store, "a", "lib", "a-delta", "Delta", Some("a-delta"));
insert_projection(&store, "b", "lib", "b-alpha", "Alpha", Some("b-alpha"));
insert_projection(&store, "b", "lib", "b-charlie", "Charlie", Some("b-charlie"));
let scopes = vec![
LibraryScopePair { server_id: "a".into(), library_id: "lib".into() },
LibraryScopePair { server_id: "b".into(), library_id: "lib".into() },
];
let first = browse(&store, &request(scopes.clone(), 2, None)).unwrap();
assert_eq!(
first.albums.iter().map(|album| album.name.as_str()).collect::<Vec<_>>(),
vec!["Alpha", "Bravo"],
);
let second = browse(&store, &request(scopes, 2, first.next_cursor)).unwrap();
assert_eq!(
second.albums.iter().map(|album| album.name.as_str()).collect::<Vec<_>>(),
vec!["Charlie", "Delta"],
);
}
}
+10 -1
View File
@@ -12,7 +12,7 @@ use tauri::Manager;
///
/// Migration checklist (wiring, data backfill, open/swap path):
/// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 19;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 20;
/// One-time data repair after migration 014 (`artist.name_sort`).
pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1";
@@ -63,6 +63,9 @@ pub(crate) const MIGRATION_018_ARTIST_SYNCED_INDEX: &str =
/// suffix-selective lossless browse index.
pub(crate) const MIGRATION_019_MAINSTAGE_FEED_INDEXES: &str =
include_str!("../migrations/019_mainstage_feed_indexes.sql");
/// Version 20: materialized per-library album rows for keyset scope browse.
pub(crate) const MIGRATION_020_SCOPE_BROWSE_PROJECTION: &str =
include_str!("../migrations/020_scope_browse_projection.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
@@ -76,6 +79,7 @@ const MIGRATIONS: &[(i64, &str)] = &[
(17, MIGRATION_017_LIBRARY_TAG_STATE),
(18, MIGRATION_018_ARTIST_SYNCED_INDEX),
(19, MIGRATION_019_MAINSTAGE_FEED_INDEXES),
(20, MIGRATION_020_SCOPE_BROWSE_PROJECTION),
];
/// Idempotent repair — also runs after the migration runner on every open so
@@ -97,6 +101,10 @@ pub(crate) fn ensure_entity_user_rating_schema(conn: &Connection) -> rusqlite::R
conn.execute_batch(MIGRATION_019_MAINSTAGE_FEED_INDEXES)
}
pub(crate) fn ensure_scope_browse_projection_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(MIGRATION_020_SCOPE_BROWSE_PROJECTION)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
@@ -679,6 +687,7 @@ fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()>
ensure_genre_tags_schema(conn)?;
ensure_mainstage_feed_indexes(conn)?;
ensure_entity_user_rating_schema(conn)?;
ensure_scope_browse_projection_schema(conn)?;
checkpoint_wal_conn(conn, "open")?;
Ok(())
}
+6
View File
@@ -1046,6 +1046,9 @@ pub fn run() {
psysonic_library::commands::library_genre_tags_run,
psysonic_library::commands::library_cluster_rebuild,
psysonic_library::commands::library_scope_list_albums,
psysonic_library::commands::library_scope_browse,
psysonic_library::commands::library_scope_browse_projection_inspect,
psysonic_library::commands::library_scope_browse_projection_run,
psysonic_library::commands::library_scope_list_mainstage_albums,
psysonic_library::commands::library_list_random_artists,
psysonic_library::commands::library_scope_list_artists,
@@ -1299,6 +1302,9 @@ mod specta_export {
"library_scope_album_detail",
"library_scope_artist_detail",
"library_scope_list_albums",
"library_scope_browse",
"library_scope_browse_projection_inspect",
"library_scope_browse_projection_run",
"library_scope_list_mainstage_albums",
"library_scope_list_artists",
"library_scope_search_tracks",
+19 -12
View File
@@ -9,9 +9,22 @@ function MigrationModal() {
const step = useMigrationStore(s => s.step);
const progress = useMigrationStore(s => s.progress);
const genreTagsProgress = useMigrationStore(s => s.genreTagsProgress);
const scopeBrowseProjectionProgress = useMigrationStore(s => s.scopeBrowseProjectionProgress);
const inspect = useMigrationStore(s => s.inspect);
const error = useMigrationStore(s => s.lastError);
const isGenreTags = step === 'genreTags';
const isScopeBrowseProjection = step === 'scopeBrowseProjection';
const migrationTitle = isGenreTags
? t('migration.genreTagsTitle')
: isScopeBrowseProjection
? t('migration.scopeBrowseProjectionTitle')
: t('migration.migrating');
const migrationBody = isGenreTags
? t('migration.genreTagsBody')
: isScopeBrowseProjection
? t('migration.scopeBrowseProjectionBody')
: (progress ? `${progress.stage} - ${progress.table}` : t('migration.working'));
const activeProgress = isGenreTags ? genreTagsProgress : isScopeBrowseProjection ? scopeBrowseProjectionProgress : progress;
const migratedRows = (inspect?.library.totalLegacyRows ?? 0) + (inspect?.analysis.totalLegacyRows ?? 0);
return (
@@ -35,26 +48,20 @@ function MigrationModal() {
>
{phase === 'inspecting' && (
<>
<h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.preparing')}</h3>
<h3>{isGenreTags ? t('migration.genreTagsTitle') : isScopeBrowseProjection ? t('migration.scopeBrowseProjectionTitle') : t('migration.preparing')}</h3>
<p style={{ color: 'var(--text-muted)' }}>
{isGenreTags ? t('migration.genreTagsBody') : t('migration.preparingBody')}
{isGenreTags ? t('migration.genreTagsBody') : isScopeBrowseProjection ? t('migration.scopeBrowseProjectionBody') : t('migration.preparingBody')}
</p>
</>
)}
{phase === 'running' && (
<>
<h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.migrating')}</h3>
<h3>{migrationTitle}</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{isGenreTags
? t('migration.genreTagsBody')
: (progress ? `${progress.stage} - ${progress.table}` : t('migration.working'))}
{migrationBody}
</p>
<p style={{ color: 'var(--text-muted)' }}>
{isGenreTags
? (genreTagsProgress
? `${genreTagsProgress.done} / ${genreTagsProgress.total}`
: t('migration.working'))
: (progress ? `${progress.done} / ${progress.total}` : t('migration.working'))}
{activeProgress ? `${activeProgress.done} / ${activeProgress.total}` : t('migration.working')}
</p>
{!isGenreTags && inspect?.hasSkippedUnknownServerRows ? (
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>
@@ -65,7 +72,7 @@ function MigrationModal() {
)}
{phase === 'error' && (
<>
<h3>{isGenreTags ? t('migration.genreTagsFailed') : t('migration.failed')}</h3>
<h3>{isGenreTags ? t('migration.genreTagsFailed') : isScopeBrowseProjection ? t('migration.scopeBrowseProjectionFailed') : t('migration.failed')}</h3>
<p style={{ color: 'var(--text-muted)' }}>{String(error ?? '').slice(0, 200)}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button className="btn-primary" onClick={() => retryBlockingMigration()}>{t('migration.retry')}</button>
@@ -7,6 +7,8 @@ const migrationInspectMock = vi.fn();
const migrationRunMock = vi.fn();
const libraryGenreTagsInspectMock = vi.fn();
const libraryGenreTagsRunMock = vi.fn();
const libraryScopeBrowseProjectionInspectMock = vi.fn();
const libraryScopeBrowseProjectionRunMock = vi.fn();
const rewriteFrontendStoreKeysMock = vi.fn(async (_servers: unknown) => undefined);
vi.mock('@tauri-apps/api/event', () => ({
@@ -21,6 +23,8 @@ vi.mock('@/lib/api/migration', () => ({
vi.mock('@/lib/api/library', () => ({
libraryGenreTagsInspect: () => libraryGenreTagsInspectMock(),
libraryGenreTagsRun: () => libraryGenreTagsRunMock(),
libraryScopeBrowseProjectionInspect: () => libraryScopeBrowseProjectionInspectMock(),
libraryScopeBrowseProjectionRun: () => libraryScopeBrowseProjectionRunMock(),
}));
vi.mock('@/utils/server/rewriteFrontendStoreKeys', () => ({
@@ -38,8 +42,12 @@ describe('useMigrationOrchestrator', () => {
migrationRunMock.mockReset();
libraryGenreTagsInspectMock.mockReset();
libraryGenreTagsRunMock.mockReset();
libraryScopeBrowseProjectionInspectMock.mockReset();
libraryScopeBrowseProjectionRunMock.mockReset();
libraryGenreTagsInspectMock.mockResolvedValue({ needed: false, totalTracks: 0, doneTracks: 0 });
libraryGenreTagsRunMock.mockResolvedValue(undefined);
libraryScopeBrowseProjectionInspectMock.mockResolvedValue({ needed: false, totalTracks: 0, doneTracks: 0 });
libraryScopeBrowseProjectionRunMock.mockResolvedValue(undefined);
rewriteFrontendStoreKeysMock.mockClear();
localStorage.clear();
useAuthStore.setState({
@@ -57,6 +65,8 @@ describe('useMigrationOrchestrator', () => {
progress: null,
genreTagsInspect: null,
genreTagsProgress: null,
scopeBrowseProjectionInspect: null,
scopeBrowseProjectionProgress: null,
lastError: null,
});
(globalThis as Record<string, unknown>)[REAL_MIGRATION_TEST_OVERRIDE] = true;
@@ -193,4 +203,33 @@ describe('useMigrationOrchestrator', () => {
expect(useMigrationStore.getState().phase).toBe('completed');
});
});
it('keeps startup non-blocking while scope-browse projection inspect is pending', async () => {
localStorage.setItem(DONE_FLAG, '1');
migrationInspectMock.mockResolvedValue({
needsMigration: false, hasSkippedUnknownServerRows: false, canRun: true, warnings: [],
unmappedEmptyBucket: false,
library: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
analysis: { totalLegacyRows: 0, skippedUnknownServerRows: 0, tables: {} },
mappings: [{ legacyId: 'legacy-a', indexKey: 'a.test' }],
});
let resolveProjection: ((value: unknown) => void) | undefined;
libraryScopeBrowseProjectionInspectMock.mockImplementation(
() => new Promise(resolve => { resolveProjection = resolve; }),
);
renderHook(() => useMigrationOrchestrator());
await waitFor(() => {
expect(libraryScopeBrowseProjectionInspectMock).toHaveBeenCalledTimes(1);
});
expect(useMigrationStore.getState().phase).toBe('idle');
if (!resolveProjection) throw new Error('projection inspect resolver not captured');
resolveProjection({ needed: false, totalTracks: 100, doneTracks: 100 });
await waitFor(() => {
expect(useMigrationStore.getState().phase).toBe('completed');
});
});
});
+43 -2
View File
@@ -1,6 +1,11 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { libraryGenreTagsInspect, libraryGenreTagsRun } from '@/lib/api/library';
import {
libraryGenreTagsInspect,
libraryGenreTagsRun,
libraryScopeBrowseProjectionInspect,
libraryScopeBrowseProjectionRun,
} from '@/lib/api/library';
import { migrationInspect, migrationRun, type ServerIndexMapping } from '@/lib/api/migration';
import { useAuthStore } from '@/store/authStore';
import { useMigrationStore } from '@/store/migrationStore';
@@ -67,6 +72,28 @@ async function runGenreTagsPhase(): Promise<void> {
}
}
async function runScopeBrowseProjectionPhase(): Promise<void> {
const state = useMigrationStore.getState();
state.setScopeBrowseProjectionProgress(null);
const inspect = await libraryScopeBrowseProjectionInspect();
state.setScopeBrowseProjectionInspect(inspect);
if (!inspect.needed) return;
state.setStep('scopeBrowseProjection');
state.setError(null);
state.setPhase('running');
await libraryScopeBrowseProjectionRun();
const after = await libraryScopeBrowseProjectionInspect();
state.setScopeBrowseProjectionInspect(after);
if (after.needed) {
state.setError('Library browse index update incomplete. Retry after restart.');
state.setPhase('error');
throw new Error('scope_browse_projection_incomplete');
}
state.setStep(null);
state.setScopeBrowseProjectionProgress(null);
}
async function runOrchestrator(force = false): Promise<void> {
if (migrationInFlight) {
await migrationInFlight;
@@ -101,6 +128,7 @@ async function runOrchestrator(force = false): Promise<void> {
skippedLogged = logSkippedUnknownRowsOnce(inspect, skippedLogged);
if (!inspect.needsMigration) {
await runGenreTagsPhase();
await runScopeBrowseProjectionPhase();
state.setPhase('completed');
return;
}
@@ -115,6 +143,7 @@ async function runOrchestrator(force = false): Promise<void> {
await rewriteFrontendStoreKeys(servers);
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
await runGenreTagsPhase();
await runScopeBrowseProjectionPhase();
state.setPhase('completed');
return;
}
@@ -129,7 +158,8 @@ async function runOrchestrator(force = false): Promise<void> {
logSkippedUnknownRowsOnce(after, skippedLogged);
if (!after.needsMigration) {
localStorage.setItem(MIGRATION_DONE_FLAG, '1');
await runGenreTagsPhase();
await runGenreTagsPhase();
await runScopeBrowseProjectionPhase();
state.setPhase('completed');
return;
}
@@ -181,6 +211,10 @@ export function retryBlockingMigration(): void {
retryGenreTagsMigration();
return;
}
if (step === 'scopeBrowseProjection') {
void runOrchestrator();
return;
}
retryServerIndexMigration();
}
@@ -206,6 +240,13 @@ export function useMigrationOrchestrator(): void {
total: number;
});
}),
listen('scope_browse_projection:progress', (event) => {
if (disposed) return;
useMigrationStore.getState().setScopeBrowseProjectionProgress(event.payload as {
done: number;
total: number;
});
}),
];
return () => {
disposed = true;
+25
View File
@@ -172,6 +172,12 @@ export interface SyncJobDto {
kind: string; // 'initial_sync' | 'delta_sync'
}
export interface ScopeBrowseProjectionInspectDto {
needed: boolean;
totalTracks: number;
doneTracks: number;
}
// ── Advanced Search (PR-5d, §5.13 / §5.5B) ────────────────────────────
export type LibraryEntityType = 'artist' | 'album' | 'track';
@@ -202,6 +208,25 @@ export interface LibraryScopePair {
libraryId: string;
}
export type LibraryScopeBrowseEntity = 'album' | 'artist' | 'track';
export interface LibraryScopeBrowseRequest {
entity: LibraryScopeBrowseEntity;
scopes: LibraryScopePair[];
sort?: LibrarySortClause[];
limit: number;
cursor?: string | null;
}
export interface LibraryScopeBrowseResponse {
albums: LibraryAlbumDto[];
artists: LibraryArtistDto[];
tracks: LibraryTrackDto[];
nextCursor?: string | null;
hasMore: boolean;
source: string;
}
export interface LibraryAdvancedSearchRequest {
serverId: string;
libraryScope?: string | null;
+10
View File
@@ -5,10 +5,12 @@
*/
import { commands } from '@/generated/bindings';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import type {
LibrarySyncProgressPayload,
LibrarySyncIdlePayload,
GenreTagsInspectDto,
ScopeBrowseProjectionInspectDto,
} from './dto';
export function subscribeLibrarySyncProgress(
@@ -37,3 +39,11 @@ export async function libraryGenreTagsRun(): Promise<void> {
const res = await commands.libraryGenreTagsRun();
if (res.status === 'error') throw new Error(res.error);
}
export function libraryScopeBrowseProjectionInspect(): Promise<ScopeBrowseProjectionInspectDto> {
return invoke('library_scope_browse_projection_inspect');
}
export function libraryScopeBrowseProjectionRun(): Promise<void> {
return invoke('library_scope_browse_projection_run');
}
+19
View File
@@ -15,6 +15,8 @@ import type {
GenreAlbumCountRow,
LibraryScopePair,
LibraryTrackDto,
LibraryScopeBrowseRequest,
LibraryScopeBrowseResponse,
} from './dto';
export type { LibraryScopePair };
@@ -122,6 +124,23 @@ export function libraryScopeListAlbums(
}).then(albums => mapAlbumsServerId(albums, serverId));
}
export function libraryScopeBrowse(
serverId: string,
request: LibraryScopeBrowseRequest,
): Promise<LibraryScopeBrowseResponse> {
return invoke<LibraryScopeBrowseResponse>('library_scope_browse', {
request: {
...request,
scopes: mapScopePairs(request.scopes, serverId),
},
}).then(response => ({
...response,
albums: mapAlbumsServerId(response.albums, serverId),
artists: mapArtistsServerId(response.artists, serverId),
tracks: mapTracksServerId(response.tracks, serverId),
}));
}
export function libraryScopeListArtists(
serverId: string,
request: LibraryScopeListRequest,
+34 -1
View File
@@ -1,4 +1,4 @@
import { libraryAdvancedSearch, libraryListAlbumsByGenre } from '@/lib/api/library';
import { libraryAdvancedSearch, libraryListAlbumsByGenre, libraryScopeBrowse } from '@/lib/api/library';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { libraryScopeForServer, libraryScopePairsForServer } from '@/lib/api/subsonicClient';
import { dedupeById } from '@/lib/util/dedupeById';
@@ -155,3 +155,36 @@ export async function runLocalAlbumBrowse(
return null;
}
}
/** Indexed candidate-first page for the ordinary unfiltered All Albums catalogue. */
export async function runLocalAlbumScopeBrowse(
serverId: string,
sort: AlbumBrowseQuery['sort'],
limit: number,
cursor?: string | null,
): Promise<{ albums: SubsonicAlbum[]; hasMore: boolean; nextCursor?: string | null } | null> {
if (!serverId) return null;
const scope = getLibraryBrowseScope();
if (scope.pairs.length === 0) return null;
try {
const response = await albumBrowseTimed(
'scope_browse',
() => libraryScopeBrowse(serverId, {
entity: 'album',
scopes: scope.pairs,
sort: albumSortClauses(sort),
limit,
cursor,
}),
{ scopeCount: scope.pairs.length, limit, cursor: cursor != null },
);
if (response.source !== 'local') return null;
return {
albums: response.albums.map(albumToAlbum),
hasMore: response.hasMore,
nextCursor: response.nextCursor,
};
} catch {
return null;
}
}
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Актуализиране на индекса на жанровете…',
genreTagsBody: 'Индексиране на жанрове за преглед и филтри. Това се изпълнява веднъж след надграждане.',
genreTagsFailed: 'Актуализацията на индекса на жанровете се провали',
scopeBrowseProjectionTitle: 'Актуализиране на индекса за разглеждане на библиотеката…',
scopeBrowseProjectionBody: 'Подготвяне на каталога с албуми за по-бързо разглеждане. Изпълнява се веднъж след обновяване.',
scopeBrowseProjectionFailed: 'Актуализацията на индекса за разглеждане на библиотеката се провали',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Genre-Index wird aktualisiert…',
genreTagsBody: 'Genres werden für Durchsuchen und Filter indexiert. Läuft einmal nach dem Update.',
genreTagsFailed: 'Genre-Index-Aktualisierung fehlgeschlagen',
scopeBrowseProjectionTitle: 'Bibliotheksindex wird aktualisiert…',
scopeBrowseProjectionBody: 'Der Albumkatalog wird für schnelleres Durchsuchen vorbereitet. Dies läuft einmal nach dem Update.',
scopeBrowseProjectionFailed: 'Aktualisierung des Bibliotheksindex fehlgeschlagen',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Updating genre index…',
genreTagsBody: 'Indexing genres for browse and filters. This runs once after upgrade.',
genreTagsFailed: 'Genre index update failed',
scopeBrowseProjectionTitle: 'Updating library browse index…',
scopeBrowseProjectionBody: 'Preparing your album catalogue for faster browsing. This runs once after upgrade.',
scopeBrowseProjectionFailed: 'Library browse index update failed',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Actualizando índice de géneros…',
genreTagsBody: 'Indexando géneros para exploración y filtros. Se ejecuta una vez tras la actualización.',
genreTagsFailed: 'Error al actualizar el índice de géneros',
scopeBrowseProjectionTitle: 'Actualizando el índice de exploración de la biblioteca…',
scopeBrowseProjectionBody: 'Preparando tu catálogo de álbumes para una exploración más rápida. Se ejecuta una vez tras actualizar.',
scopeBrowseProjectionFailed: 'Error al actualizar el índice de exploración de la biblioteca',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Mise à jour de l\'index des genres…',
genreTagsBody: 'Indexation des genres pour la navigation et les filtres. Exécuté une fois après la mise à jour.',
genreTagsFailed: 'Échec de la mise à jour de l\'index des genres',
scopeBrowseProjectionTitle: 'Mise à jour de l\'index de navigation de la bibliothèque…',
scopeBrowseProjectionBody: 'Préparation de votre catalogue d\'albums pour une navigation plus rapide. Cette opération ne s\'exécute qu\'une fois après la mise à jour.',
scopeBrowseProjectionFailed: 'Échec de la mise à jour de l\'index de navigation de la bibliothèque',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Műfajindex frissítése…',
genreTagsBody: 'Műfajok indexelése a böngészéshez és a szűrőkhöz. Ez frissítés után egyszer fut le.',
genreTagsFailed: 'A műfajindex frissítése nem sikerült',
scopeBrowseProjectionTitle: 'A könyvtárböngészési index frissítése…',
scopeBrowseProjectionBody: 'Az albumkatalógus előkészítése a gyorsabb böngészéshez. Ez frissítés után egyszer fut le.',
scopeBrowseProjectionFailed: 'A könyvtárböngészési index frissítése nem sikerült',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Aggiornamento indice generi…',
genreTagsBody: 'Indicizzazione dei generi per la navigazione e i filtri. Questa operazione viene eseguita una sola volta dopo l\'aggiornamento.',
genreTagsFailed: 'Aggiornamento indice generi fallito',
scopeBrowseProjectionTitle: 'Aggiornamento dell\'indice di navigazione della libreria…',
scopeBrowseProjectionBody: 'Preparazione del catalogo degli album per una navigazione più rapida. Viene eseguito una volta dopo l\'aggiornamento.',
scopeBrowseProjectionFailed: 'Aggiornamento dell\'indice di navigazione della libreria non riuscito',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'ジャンルインデックスを更新中…',
genreTagsBody: 'ブラウズとフィルター用にジャンルをインデックスしています。アップグレード後に一度だけ実行されます。',
genreTagsFailed: 'ジャンルインデックスの更新に失敗しました',
scopeBrowseProjectionTitle: 'ライブラリ閲覧インデックスを更新中…',
scopeBrowseProjectionBody: 'より速く閲覧できるようにアルバムカタログを準備しています。更新後に一度だけ実行されます。',
scopeBrowseProjectionFailed: 'ライブラリ閲覧インデックスの更新に失敗しました',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Oppdaterer sjangerindeks…',
genreTagsBody: 'Indekserer sjangre for blaing og filtre. Kjøres én gang etter oppgradering.',
genreTagsFailed: 'Oppdatering av sjangerindeks mislyktes',
scopeBrowseProjectionTitle: 'Oppdaterer bibliotekets nettlesingsindeks…',
scopeBrowseProjectionBody: 'Forbereder albumkatalogen for raskere navigering. Dette kjøres én gang etter oppdatering.',
scopeBrowseProjectionFailed: 'Kunne ikke oppdatere bibliotekets nettlesingsindeks',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Genre-index bijwerken…',
genreTagsBody: 'Genres indexeren voor bladeren en filters. Eenmalig na de update.',
genreTagsFailed: 'Genre-index bijwerken mislukt',
scopeBrowseProjectionTitle: 'Browse-index van bibliotheek bijwerken…',
scopeBrowseProjectionBody: 'Je albumcatalogus wordt voorbereid voor sneller bladeren. Dit gebeurt één keer na de update.',
scopeBrowseProjectionFailed: 'Bijwerken van browse-index van bibliotheek mislukt',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Aktualizowanie indeksu gatunków…',
genreTagsBody: 'Indeksowanie gatunków do przeglądania i filtrów. Uruchamia się jednorazowo po aktualizacji.',
genreTagsFailed: 'Aktualizacja indeksu gatunków nie powiodła się',
scopeBrowseProjectionTitle: 'Aktualizowanie indeksu przeglądania biblioteki…',
scopeBrowseProjectionBody: 'Przygotowywanie katalogu albumów do szybszego przeglądania. Uruchamia się raz po aktualizacji.',
scopeBrowseProjectionFailed: 'Nie udało się zaktualizować indeksu przeglądania biblioteki',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Se actualizează indexul de genuri…',
genreTagsBody: 'Se indexează genurile pentru navigare și filtre. Rulează o dată după actualizare.',
genreTagsFailed: 'Actualizarea indexului de genuri a eșuat',
scopeBrowseProjectionTitle: 'Se actualizează indexul de navigare al bibliotecii…',
scopeBrowseProjectionBody: 'Se pregătește catalogul de albume pentru navigare mai rapidă. Aceasta rulează o singură dată după actualizare.',
scopeBrowseProjectionFailed: 'Actualizarea indexului de navigare al bibliotecii a eșuat',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: 'Обновление индекса жанров…',
genreTagsBody: 'Индексируем жанры для просмотра и фильтров. Выполняется один раз после обновления.',
genreTagsFailed: 'Не удалось обновить индекс жанров',
scopeBrowseProjectionTitle: 'Обновление индекса библиотеки…',
scopeBrowseProjectionBody: 'Подготавливаем каталог альбомов для более быстрого просмотра. Выполняется один раз после обновления.',
scopeBrowseProjectionFailed: 'Не удалось обновить индекс библиотеки',
};
+3
View File
@@ -12,4 +12,7 @@ export const migration = {
genreTagsTitle: '正在更新流派索引…',
genreTagsBody: '正在为浏览和筛选建立流派索引。升级后仅运行一次。',
genreTagsFailed: '流派索引更新失败',
scopeBrowseProjectionTitle: '正在更新资料库浏览索引…',
scopeBrowseProjectionBody: '正在准备专辑目录以便更快浏览。更新后只会运行一次。',
scopeBrowseProjectionFailed: '更新资料库浏览索引失败',
};
+10 -2
View File
@@ -1,9 +1,9 @@
import { create } from 'zustand';
import type { GenreTagsInspectDto } from '@/lib/api/library';
import type { GenreTagsInspectDto, ScopeBrowseProjectionInspectDto } from '@/lib/api/library';
import type { MigrationInspectReport, MigrationProgressEvent } from '@/lib/api/migration';
export type MigrationPhase = 'idle' | 'inspecting' | 'running' | 'completed' | 'error';
export type MigrationStep = 'serverIndex' | 'genreTags';
export type MigrationStep = 'serverIndex' | 'genreTags' | 'scopeBrowseProjection';
export interface GenreTagsProgressEvent {
done: number;
@@ -18,6 +18,8 @@ interface MigrationState {
progress: MigrationProgressEvent | null;
genreTagsInspect: GenreTagsInspectDto | null;
genreTagsProgress: GenreTagsProgressEvent | null;
scopeBrowseProjectionInspect: ScopeBrowseProjectionInspectDto | null;
scopeBrowseProjectionProgress: GenreTagsProgressEvent | null;
lastError: string | null;
setPhase: (phase: MigrationPhase) => void;
setStep: (step: MigrationStep | null) => void;
@@ -26,6 +28,8 @@ interface MigrationState {
setProgress: (event: MigrationProgressEvent | null) => void;
setGenreTagsInspect: (report: GenreTagsInspectDto | null) => void;
setGenreTagsProgress: (event: GenreTagsProgressEvent | null) => void;
setScopeBrowseProjectionInspect: (report: ScopeBrowseProjectionInspectDto | null) => void;
setScopeBrowseProjectionProgress: (event: GenreTagsProgressEvent | null) => void;
setError: (error: string | null) => void;
}
@@ -37,6 +41,8 @@ export const useMigrationStore = create<MigrationState>(set => ({
progress: null,
genreTagsInspect: null,
genreTagsProgress: null,
scopeBrowseProjectionInspect: null,
scopeBrowseProjectionProgress: null,
lastError: null,
setPhase: phase => set({ phase }),
setStep: step => set({ step }),
@@ -45,5 +51,7 @@ export const useMigrationStore = create<MigrationState>(set => ({
setProgress: progress => set({ progress }),
setGenreTagsInspect: genreTagsInspect => set({ genreTagsInspect }),
setGenreTagsProgress: genreTagsProgress => set({ genreTagsProgress }),
setScopeBrowseProjectionInspect: scopeBrowseProjectionInspect => set({ scopeBrowseProjectionInspect }),
setScopeBrowseProjectionProgress: scopeBrowseProjectionProgress => set({ scopeBrowseProjectionProgress }),
setError: lastError => set({ lastError }),
}));