Duncan Tourolle ada3ed64ab Workstream E (backend): wire tauri-specta — annotate commands, derive Type, generate-ready Builder
- Add #[specta::specta] to all 201 #[tauri::command] functions.
- Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/
  download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState,
  ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.).
- Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands!
  in lib.rs (exports bindings.ts in debug builds).

Two contract changes required by specta constraints (frontend migration follows):
- specta caps command arity at 10 args: download_item_and_start / download_item /
  download_video now take a single request struct (params bundled, body unchanged
  via destructuring).
- specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState
  switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these
  now serialize PascalCase to the frontend).

cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
2026-06-20 18:20:25 +02:00

159 lines
4.6 KiB
Rust

//! Tauri commands for offline data access
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
#[derive(specta::Type, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfflineItem {
pub id: String,
pub name: String,
pub item_type: String,
pub album_id: Option<String>,
pub album_name: Option<String>,
pub artists: Option<String>,
pub runtime_ticks: Option<i64>,
pub primary_image_tag: Option<String>,
}
/// Check if an item is available offline
#[tauri::command]
#[specta::specta]
pub async fn offline_is_available(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<bool, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT COUNT(*) FROM downloads WHERE item_id = ? AND status = 'completed'",
vec![QueryParam::String(item_id)],
);
let count: i64 = db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
Ok(count > 0)
}
/// Get all offline items for a user
#[tauri::command]
#[specta::specta]
pub async fn offline_get_items(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<Vec<OfflineItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
i.runtime_ticks, i.primary_image_tag
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.user_id = ? AND d.status = 'completed'
ORDER BY d.completed_at DESC",
vec![QueryParam::String(user_id)],
);
db_service
.query_many(query, |row| {
Ok(OfflineItem {
id: row.get(0)?,
name: row.get(1)?,
item_type: row.get(2)?,
album_id: row.get(3)?,
album_name: row.get(4)?,
artists: row.get(5)?,
runtime_ticks: row.get(6)?,
primary_image_tag: row.get(7)?,
})
})
.await
.map_err(|e| e.to_string())
}
/// Search offline items
#[tauri::command]
#[specta::specta]
pub async fn offline_search(
db: State<'_, DatabaseWrapper>,
user_id: String,
query: String,
) -> Result<Vec<OfflineItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let search_query = format!("%{}%", query.to_lowercase());
let db_query = Query::with_params(
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
i.runtime_ticks, i.primary_image_tag
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.user_id = ? AND d.status = 'completed'
AND (LOWER(i.name) LIKE ? OR LOWER(i.artists) LIKE ? OR LOWER(i.album_name) LIKE ?)
ORDER BY i.name
LIMIT 50",
vec![
QueryParam::String(user_id),
QueryParam::String(search_query.clone()),
QueryParam::String(search_query.clone()),
QueryParam::String(search_query),
],
);
db_service
.query_many(db_query, |row| {
Ok(OfflineItem {
id: row.get(0)?,
name: row.get(1)?,
item_type: row.get(2)?,
album_id: row.get(3)?,
album_name: row.get(4)?,
artists: row.get(5)?,
runtime_ticks: row.get(6)?,
primary_image_tag: row.get(7)?,
})
})
.await
.map_err(|e| e.to_string())
}
// TRACES: UR-002, UR-011 | DR-017 | UT-044
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offline_item_serialization() {
let item = OfflineItem {
id: "123".to_string(),
name: "Test Song".to_string(),
item_type: "Audio".to_string(),
album_id: Some("album1".to_string()),
album_name: Some("Test Album".to_string()),
artists: Some("Artist 1".to_string()),
runtime_ticks: Some(180000000),
primary_image_tag: Some("tag123".to_string()),
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"itemType\":\"Audio\""));
assert!(json.contains("\"albumName\":\"Test Album\""));
}
}