mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(library): add scoped browse projection foundation
This commit is contained in:
@@ -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"],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,7 @@ export const migration = {
|
||||
genreTagsTitle: 'Актуализиране на индекса на жанровете…',
|
||||
genreTagsBody: 'Индексиране на жанрове за преглед и филтри. Това се изпълнява веднъж след надграждане.',
|
||||
genreTagsFailed: 'Актуализацията на индекса на жанровете се провали',
|
||||
scopeBrowseProjectionTitle: 'Актуализиране на индекса за разглеждане на библиотеката…',
|
||||
scopeBrowseProjectionBody: 'Подготвяне на каталога с албуми за по-бързо разглеждане. Изпълнява се веднъж след обновяване.',
|
||||
scopeBrowseProjectionFailed: 'Актуализацията на индекса за разглеждане на библиотеката се провали',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -12,4 +12,7 @@ export const migration = {
|
||||
genreTagsTitle: 'ジャンルインデックスを更新中…',
|
||||
genreTagsBody: 'ブラウズとフィルター用にジャンルをインデックスしています。アップグレード後に一度だけ実行されます。',
|
||||
genreTagsFailed: 'ジャンルインデックスの更新に失敗しました',
|
||||
scopeBrowseProjectionTitle: 'ライブラリ閲覧インデックスを更新中…',
|
||||
scopeBrowseProjectionBody: 'より速く閲覧できるようにアルバムカタログを準備しています。更新後に一度だけ実行されます。',
|
||||
scopeBrowseProjectionFailed: 'ライブラリ閲覧インデックスの更新に失敗しました',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -12,4 +12,7 @@ export const migration = {
|
||||
genreTagsTitle: 'Обновление индекса жанров…',
|
||||
genreTagsBody: 'Индексируем жанры для просмотра и фильтров. Выполняется один раз после обновления.',
|
||||
genreTagsFailed: 'Не удалось обновить индекс жанров',
|
||||
scopeBrowseProjectionTitle: 'Обновление индекса библиотеки…',
|
||||
scopeBrowseProjectionBody: 'Подготавливаем каталог альбомов для более быстрого просмотра. Выполняется один раз после обновления.',
|
||||
scopeBrowseProjectionFailed: 'Не удалось обновить индекс библиотеки',
|
||||
};
|
||||
|
||||
@@ -12,4 +12,7 @@ export const migration = {
|
||||
genreTagsTitle: '正在更新流派索引…',
|
||||
genreTagsBody: '正在为浏览和筛选建立流派索引。升级后仅运行一次。',
|
||||
genreTagsFailed: '流派索引更新失败',
|
||||
scopeBrowseProjectionTitle: '正在更新资料库浏览索引…',
|
||||
scopeBrowseProjectionBody: '正在准备专辑目录以便更快浏览。更新后只会运行一次。',
|
||||
scopeBrowseProjectionFailed: '更新资料库浏览索引失败',
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user