Skip to main content

jellytau_lib/commands/
repository.rs

1//! Tauri commands for repository access
2//! Uses handle-based system: UUID -> Arc<HybridRepository>
3//!
4//! TRACES: UR-007, UR-008, UR-023, UR-034, UR-035, UR-036 | IR-022, IR-024, JA-004, JA-005, JA-006, JA-029, JA-030, JA-031
5
6use crate::utils::lock::MutexSafe;
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10use log::{debug, error, info, warn};
11use serde::{Deserialize, Serialize};
12use tauri::{AppHandle, Emitter, State};
13use uuid::Uuid;
14
15use crate::domain::rank_search_results;
16use crate::jellyfin::HttpClient;
17use crate::repository::capabilities::ServerCapabilities;
18use crate::repository::{
19    series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
20    OnlineRepository, StreamSelection,
21};
22
23/// Repository handle manager
24pub struct RepositoryManager {
25    repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
26}
27
28impl RepositoryManager {
29    pub fn new() -> Self {
30        Self {
31            repositories: Arc::new(Mutex::new(HashMap::new())),
32        }
33    }
34
35    pub fn create(&self, handle: String, repository: HybridRepository) {
36        let mut repos = self.repositories.lock_safe();
37        repos.insert(handle, Arc::new(repository));
38    }
39
40    pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
41        let repos = self.repositories.lock_safe();
42        repos.get(handle).cloned()
43    }
44
45    /// Handles of every live repository.
46    ///
47    /// The background catalog indexer (DR-109) runs outside any command, so it
48    /// has no handle passed in and needs to discover one. In practice there is a
49    /// single signed-in repository; returning all of them avoids inventing an
50    /// "active" concept the rest of the code does not have.
51    ///
52    /// TRACES: UR-065 | DR-109
53    pub fn handles(&self) -> Vec<String> {
54        let repos = self.repositories.lock_safe();
55        repos.keys().cloned().collect()
56    }
57
58    pub fn destroy(&self, handle: &str) {
59        let mut repos = self.repositories.lock_safe();
60        repos.remove(handle);
61    }
62}
63
64/// Wrapper for Tauri state
65pub struct RepositoryManagerWrapper(pub RepositoryManager);
66
67/// Read the server's reported version and resolve it into capabilities.
68///
69/// Never fails: a server row that is missing, or carries a version this build
70/// cannot parse, yields the conservative generation rather than an error. A
71/// client that refused to start because it did not recognise a version string
72/// would be the exact failure UR-085 exists to remove.
73///
74/// TRACES: UR-085 | IR-035, DR-280
75async fn server_capabilities(
76    db: &Arc<crate::storage::db_service::RusqliteService>,
77    server_id: &str,
78) -> ServerCapabilities {
79    use crate::storage::db_service::{DatabaseService, Query, QueryParam};
80
81    let reported: Option<String> = db
82        .query_one(
83            Query::with_params(
84                "SELECT version FROM servers WHERE id = ?1",
85                vec![QueryParam::String(server_id.to_string())],
86            ),
87            |row| row.get::<_, Option<String>>(0),
88        )
89        .await
90        .ok()
91        .flatten();
92
93    match reported {
94        Some(version) => ServerCapabilities::from_reported(version.as_str()),
95        None => {
96            debug!("[REPO] No server version recorded for {server_id}; assuming current target");
97            ServerCapabilities::assumed()
98        }
99    }
100}
101
102/// Drop the cached catalog if the server changed generation since we last looked.
103///
104/// Returns whether anything was invalidated, which is what the tests assert on.
105///
106/// The first run after this feature ships records the generation and invalidates
107/// nothing: a NULL column means "never recorded", not "changed". Making the
108/// absence of information trigger a full re-fetch would charge every existing
109/// user bandwidth for a server upgrade that has not happened.
110///
111/// TRACES: UR-085 | DR-284
112async fn invalidate_cache_on_generation_change(
113    db: &Arc<crate::storage::db_service::RusqliteService>,
114    server_id: &str,
115    generation: crate::repository::capabilities::ServerGeneration,
116) -> bool {
117    use crate::storage::db_service::{DatabaseService, Query, QueryParam};
118
119    let current = format!("{generation:?}");
120
121    let previous: Option<String> = db
122        .query_one(
123            Query::with_params(
124                "SELECT catalog_generation FROM servers WHERE id = ?1",
125                vec![QueryParam::String(server_id.to_string())],
126            ),
127            |row| row.get::<_, Option<String>>(0),
128        )
129        .await
130        .ok()
131        .flatten();
132
133    let changed = matches!(previous.as_deref(), Some(prev) if prev != current);
134
135    if changed {
136        warn!(
137            "[REPO] Server generation changed ({:?} -> {}); dropping the cached catalog so it \
138             is re-fetched under the new generation's shapes",
139            previous, current
140        );
141        if let Err(e) = db
142            .execute(Query::with_params(
143                "UPDATE items SET synced_at = NULL WHERE server_id = ?1",
144                vec![QueryParam::String(server_id.to_string())],
145            ))
146            .await
147        {
148            // Not fatal: stale-but-parseable rows are better than refusing to
149            // start, and the next successful sync overwrites them anyway.
150            error!("[REPO] Failed to invalidate cached catalog: {e}");
151        }
152    }
153
154    if previous.as_deref() != Some(current.as_str()) {
155        if let Err(e) = db
156            .execute(Query::with_params(
157                "UPDATE servers SET catalog_generation = ?1 WHERE id = ?2",
158                vec![
159                    QueryParam::String(current),
160                    QueryParam::String(server_id.to_string()),
161                ],
162            ))
163            .await
164        {
165            error!("[REPO] Failed to record server generation: {e}");
166        }
167    }
168
169    changed
170}
171
172/// Create a new repository instance
173/// Returns a handle (UUID) for accessing the repository
174#[tauri::command]
175#[specta::specta]
176// Four of the eight arguments are Tauri `State<'_, _>` injections, not caller
177// input. Folding the remaining four into a struct would change the IPC contract
178// and the generated TypeScript for no readability gain.
179#[allow(clippy::too_many_arguments)]
180pub async fn repository_create(
181    manager: State<'_, RepositoryManagerWrapper>,
182    player: State<'_, crate::commands::player::PlayerStateWrapper>,
183    db: State<'_, crate::commands::storage::DatabaseWrapper>,
184    connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
185    server_url: String,
186    user_id: String,
187    access_token: String,
188    server_id: String,
189) -> Result<String, String> {
190    info!("[REPO] repository_create called for user: {}", user_id);
191
192    // Create HTTP client for online repository
193    debug!("[REPO] Creating HTTP client...");
194    let http_config = crate::jellyfin::HttpConfig::default();
195    let http_client = HttpClient::new(http_config).map_err(|e| {
196        error!("[REPO] HTTP client creation failed: {}", e);
197        e.to_string()
198    })?;
199    debug!("[REPO] HTTP client created successfully");
200
201    // Grab a connectivity reporter so the online repository's server outcomes
202    // drive the reachability state the UI observes (source of truth for the
203    // offline/online banner). See docs/architecture/07-connectivity.md.
204    let connectivity_reporter = {
205        let monitor = connectivity.0.lock().await;
206        monitor.reporter()
207    };
208
209    // Create offline repository with async-safe database service
210    debug!("[REPO] Creating database service...");
211    let db_service = {
212        let database = db.0.lock().map_err(|e| {
213            error!("[REPO] Database lock failed: {}", e);
214            e.to_string()
215        })?;
216        debug!("[REPO] Database lock acquired, getting service...");
217        Arc::new(database.service())
218    }; // Lock is released here
219    debug!("[REPO] Database service created");
220
221    // Resolve what this server can do, from the version it reported at connect.
222    // `AuthManager::connect_to_server` already parsed it and `storage` already
223    // persisted it, so this costs one indexed read and no extra round trip.
224    //
225    // A missing or unreadable version is not an error: `from_reported` treats it
226    // as the older generation, whose request shapes also work on the newer one.
227    //
228    // TRACES: UR-085 | IR-035, DR-280
229    let capabilities = server_capabilities(&db_service, &server_id).await;
230    info!(
231        "[REPO] Server generation: {:?} (reported {:?})",
232        capabilities.generation,
233        capabilities.version.as_ref().map(|v| v.raw.as_str())
234    );
235
236    // A server upgraded underneath us means the cached catalog was parsed under
237    // a different generation's assumptions. TRACES: UR-085 | DR-284
238    invalidate_cache_on_generation_change(&db_service, &server_id, capabilities.generation).await;
239
240    // Create online repository wired to connectivity reporting
241    debug!("[REPO] Creating online repository...");
242    let online = OnlineRepository::new(
243        Arc::new(http_client),
244        server_url,
245        user_id.clone(),
246        access_token,
247    )
248    .with_connectivity(connectivity_reporter)
249    .with_capabilities(capabilities);
250    debug!("[REPO] Online repository created");
251
252    debug!("[REPO] Creating offline repository...");
253    let offline = OfflineRepository::new(db_service, server_id, user_id);
254    debug!("[REPO] Offline repository created");
255
256    // Create hybrid repository
257    debug!("[REPO] Creating hybrid repository...");
258    let hybrid = HybridRepository::new(online, offline);
259    debug!("[REPO] Hybrid repository created");
260
261    // Generate handle and store repository
262    let uuid = Uuid::new_v4();
263    let handle = format!("{}", uuid);
264    info!("[REPO] Generated handle: {}", handle);
265
266    // Store repository synchronously
267    debug!("[REPO] Storing repository...");
268    manager.0.create(handle.clone(), hybrid);
269    info!("[REPO] Repository stored successfully");
270
271    // Give the player controller a repository for next-episode lookups. The
272    // Android playback-ended callback has no repository handle, so without
273    // this the episode autoplay countdown never triggers there.
274    if let Some(repo) = manager.0.get(&handle) {
275        let controller = player.0.lock().await;
276        controller.set_repository(repo);
277    }
278
279    Ok(handle)
280}
281
282/// Destroy a repository instance
283#[tauri::command]
284#[specta::specta]
285pub async fn repository_destroy(
286    manager: State<'_, RepositoryManagerWrapper>,
287    handle: String,
288) -> Result<(), String> {
289    manager.0.destroy(&handle);
290    Ok(())
291}
292
293/// Get libraries
294#[tauri::command]
295#[specta::specta]
296pub async fn repository_get_libraries(
297    manager: State<'_, RepositoryManagerWrapper>,
298    handle: String,
299) -> Result<Vec<Library>, String> {
300    debug!("[REPO] get_libraries called with handle: {}", handle);
301    let repo = manager.0.get(&handle).ok_or_else(|| {
302        error!("[REPO] Repository not found for handle: {}", handle);
303        "Repository not found".to_string()
304    })?;
305    debug!("[REPO] Repository found, fetching libraries...");
306    repo.as_ref().get_libraries().await.map_err(|e| {
307        error!("[REPO] Error fetching libraries: {:?}", e);
308        format!("{:?}", e)
309    })
310}
311
312/// Get items in a container (library, folder, album, etc.)
313#[tauri::command]
314#[specta::specta]
315pub async fn repository_get_items(
316    manager: State<'_, RepositoryManagerWrapper>,
317    handle: String,
318    parent_id: String,
319    options: Option<GetItemsOptions>,
320) -> Result<SearchResult, String> {
321    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
322    repo.as_ref()
323        .get_items(&parent_id, options)
324        .await
325        .map_err(|e| format!("{:?}", e))
326}
327
328/// Get a single item by ID
329#[tauri::command]
330#[specta::specta]
331pub async fn repository_get_item(
332    manager: State<'_, RepositoryManagerWrapper>,
333    handle: String,
334    item_id: String,
335) -> Result<MediaItem, String> {
336    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
337    repo.as_ref()
338        .get_item(&item_id)
339        .await
340        .map_err(|e| format!("{:?}", e))
341}
342
343/// Downloaded-only browse: libraries that contain downloaded content.
344///
345/// Backs the Downloads "Downloaded" surface. Never merges server results and is
346/// authoritative — an empty list means nothing is downloaded.
347///
348/// TRACES: UR-055 | DR-082
349#[tauri::command]
350#[specta::specta]
351pub async fn repository_get_downloaded_libraries(
352    manager: State<'_, RepositoryManagerWrapper>,
353    handle: String,
354) -> Result<Vec<Library>, String> {
355    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
356    repo.get_downloaded_libraries()
357        .await
358        .map_err(|e| format!("{:?}", e))
359}
360
361/// Downloaded-only browse: items under a container that are on the device.
362///
363/// TRACES: UR-055 | DR-082, DR-083
364#[tauri::command]
365#[specta::specta]
366pub async fn repository_get_downloaded_items(
367    manager: State<'_, RepositoryManagerWrapper>,
368    handle: String,
369    parent_id: String,
370    options: Option<GetItemsOptions>,
371) -> Result<SearchResult, String> {
372    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
373    repo.get_downloaded_items(&parent_id, options)
374        .await
375        .map_err(|e| format!("{:?}", e))
376}
377
378/// On-disk usage of downloaded content (device total, per-item/container bytes).
379///
380/// TRACES: UR-056 | DR-085
381#[tauri::command]
382#[specta::specta]
383pub async fn repository_get_download_disk_usage(
384    manager: State<'_, RepositoryManagerWrapper>,
385    handle: String,
386) -> Result<DownloadDiskUsage, String> {
387    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
388    repo.get_download_disk_usage()
389        .await
390        .map_err(|e| format!("{:?}", e))
391}
392
393/// Query the optional JRay plugin for the actors on screen at time `t`
394/// (seconds) in an item. Returns an empty list when JRay isn't installed or
395/// has no data for the item, so the caller can render nothing without error.
396#[tauri::command]
397#[specta::specta]
398pub async fn repository_jray_actors_at(
399    manager: State<'_, RepositoryManagerWrapper>,
400    handle: String,
401    item_id: String,
402    t: f64,
403) -> Result<Vec<crate::repository::JRayActor>, String> {
404    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
405    repo.as_ref()
406        .get_jray_actors(&item_id, t)
407        .await
408        .map_err(|e| format!("{:?}", e))
409}
410
411/// Get latest items in a library
412#[tauri::command]
413#[specta::specta]
414pub async fn repository_get_latest_items(
415    manager: State<'_, RepositoryManagerWrapper>,
416    handle: String,
417    parent_id: String,
418    limit: Option<usize>,
419) -> Result<Vec<MediaItem>, String> {
420    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
421    repo.as_ref()
422        .get_latest_items(&parent_id, limit)
423        .await
424        .map_err(|e| format!("{:?}", e))
425}
426
427/// Get resume items (continue watching/listening).
428///
429/// The home screen's Continue Watching row and every library's "pick up where
430/// you left off" hero come through here; each item carries its own resume
431/// position in `UserData`.
432///
433/// TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
434#[tauri::command]
435#[specta::specta]
436pub async fn repository_get_resume_items(
437    manager: State<'_, RepositoryManagerWrapper>,
438    handle: String,
439    parent_id: Option<String>,
440    limit: Option<usize>,
441) -> Result<Vec<MediaItem>, String> {
442    debug!("[REPO] get_resume_items called with handle: {}", handle);
443    let repo = manager.0.get(&handle).ok_or_else(|| {
444        error!("[REPO] Repository not found for handle: {}", handle);
445        "Repository not found".to_string()
446    })?;
447    debug!("[REPO] Repository found, fetching resume items...");
448    repo.as_ref()
449        .get_resume_items(parent_id.as_deref(), limit)
450        .await
451        .map_err(|e| {
452            error!("[REPO] Error fetching resume items: {:?}", e);
453            format!("{:?}", e)
454        })
455}
456
457/// Get next up episodes.
458///
459/// TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
460#[tauri::command]
461#[specta::specta]
462pub async fn repository_get_next_up_episodes(
463    manager: State<'_, RepositoryManagerWrapper>,
464    handle: String,
465    series_id: Option<String>,
466    limit: Option<usize>,
467) -> Result<Vec<MediaItem>, String> {
468    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
469    repo.as_ref()
470        .get_next_up_episodes(series_id.as_deref(), limit)
471        .await
472        .map_err(|e| format!("{:?}", e))
473}
474
475/// Every episode of a series, across all seasons, in series order.
476///
477/// Jellyfin hangs episodes off season folders — except for "flat" series whose
478/// children are episodes directly. Both shapes are provider vocabulary, so the
479/// fan-out and its fallback live in Rust rather than being reimplemented in the
480/// frontend (which is what it used to do).
481///
482/// TRACES: UR-062 | DR-101
483#[tauri::command]
484#[specta::specta]
485pub async fn repository_get_series_episodes(
486    manager: State<'_, RepositoryManagerWrapper>,
487    handle: String,
488    series_id: String,
489) -> Result<Vec<MediaItem>, String> {
490    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
491    series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
492        .await
493        .map_err(|e| format!("{:?}", e))
494}
495
496/// The episode a viewer should land on when they open a series.
497///
498/// "Current" is domain policy, not layout: an episode in progress, else the
499/// server's Next Up for the series, else the first unwatched episode, else the
500/// first. The third rung is what makes this work offline, where Next Up is
501/// always empty. Returns `None` only when the series has no episodes at all.
502///
503/// TRACES: UR-062 | DR-101
504#[tauri::command]
505#[specta::specta]
506pub async fn repository_get_series_current_episode(
507    manager: State<'_, RepositoryManagerWrapper>,
508    handle: String,
509    series_id: String,
510) -> Result<Option<MediaItem>, String> {
511    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
512    series_progress::resolve_current_episode(repo.as_ref(), &series_id)
513        .await
514        .map_err(|e| format!("{:?}", e))
515}
516
517/// A series' episodes and the viewer's current episode, from one season
518/// fan-out. The series page used to ask for these as two commands, each of
519/// which walked every season.
520///
521/// TRACES: UR-062 | DR-101, DR-295
522#[tauri::command]
523#[specta::specta]
524pub async fn repository_get_series_view(
525    manager: State<'_, RepositoryManagerWrapper>,
526    handle: String,
527    series_id: String,
528) -> Result<series_progress::SeriesView, String> {
529    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
530    series_progress::resolve_series_view(repo.as_ref(), &series_id)
531        .await
532        .map_err(|e| format!("{:?}", e))
533}
534
535/// Erase the viewer's watch history for an item.
536///
537/// Clears the played flag and the resume position; on a series or season the
538/// server applies it to everything inside. A series cleared this way is "never
539/// watched" again, so `repository_get_series_current_episode` returns its
540/// premiere. Requires the server — offline this fails rather than diverging
541/// local state the next sync would overwrite.
542///
543/// TRACES: UR-064 | DR-106
544#[tauri::command]
545#[specta::specta]
546pub async fn repository_clear_watch_history(
547    manager: State<'_, RepositoryManagerWrapper>,
548    handle: String,
549    item_id: String,
550) -> Result<(), String> {
551    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
552    repo.as_ref()
553        .clear_watch_history(&item_id)
554        .await
555        .map_err(|e| format!("{:?}", e))
556}
557
558/// Get recently played audio
559#[tauri::command]
560#[specta::specta]
561pub async fn repository_get_recently_played_audio(
562    manager: State<'_, RepositoryManagerWrapper>,
563    handle: String,
564    limit: Option<usize>,
565) -> Result<Vec<MediaItem>, String> {
566    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
567    repo.as_ref()
568        .get_recently_played_audio(limit)
569        .await
570        .map_err(|e| format!("{:?}", e))
571}
572
573/// Get resume movies
574#[tauri::command]
575#[specta::specta]
576pub async fn repository_get_resume_movies(
577    manager: State<'_, RepositoryManagerWrapper>,
578    handle: String,
579    limit: Option<usize>,
580) -> Result<Vec<MediaItem>, String> {
581    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
582    repo.as_ref()
583        .get_resume_movies(limit)
584        .await
585        .map_err(|e| format!("{:?}", e))
586}
587
588/// Get albums the user hasn't listened to recently ("rediscover")
589#[tauri::command]
590#[specta::specta]
591pub async fn repository_get_rediscover_albums(
592    manager: State<'_, RepositoryManagerWrapper>,
593    handle: String,
594    parent_id: Option<String>,
595    limit: Option<usize>,
596) -> Result<Vec<MediaItem>, String> {
597    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
598    repo.as_ref()
599        .get_rediscover_albums(parent_id.as_deref(), limit)
600        .await
601        .map_err(|e| format!("{:?}", e))
602}
603
604/// Get genres for a library
605#[tauri::command]
606#[specta::specta]
607pub async fn repository_get_genres(
608    manager: State<'_, RepositoryManagerWrapper>,
609    handle: String,
610    parent_id: Option<String>,
611) -> Result<Vec<Genre>, String> {
612    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
613    repo.as_ref()
614        .get_genres(parent_id.as_deref())
615        .await
616        .map_err(|e| format!("{:?}", e))
617}
618
619/// Tauri event name carrying the merged (cache + server) search results.
620pub const SEARCH_EVENT_NAME: &str = "search-event";
621
622/// Payload for the deferred, merged search results pushed to the frontend.
623///
624/// `request_id` matches the value the frontend passed to `repository_search`,
625/// letting it discard updates from queries that have since been superseded.
626#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
627#[serde(rename_all = "camelCase")]
628pub struct SearchUpdateEvent {
629    pub request_id: u32,
630    pub result: SearchResult,
631}
632
633/// Search for items.
634///
635/// Resolves `SearchOptions::scope` into concrete Jellyfin item types before
636/// dispatching, so scope taxonomy stays in Rust.
637///
638/// TRACES: UR-049, UR-050 | DR-063
639#[tauri::command]
640#[specta::specta]
641pub async fn repository_search(
642    app: AppHandle,
643    manager: State<'_, RepositoryManagerWrapper>,
644    handle: String,
645    query: String,
646    options: Option<SearchOptions>,
647    request_id: u32,
648) -> Result<SearchResult, String> {
649    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
650
651    // Expand the opaque scope into item types HERE — once, before the cache and
652    // server paths diverge — so both phases filter identically. Doing it later
653    // (or in only one path) makes offline results disagree with online ones.
654    // The frontend sends `scope` and never names a Jellyfin item type for
655    // search; see docs/specs/scoped-search-boundary.md.
656    let options = options.map(|mut o| {
657        o.resolve_scope();
658        o
659    });
660
661    // Phase 1: instant local results from the cache (downloaded content) so the
662    // UI can render immediately while the server is still being queried.
663    let mut cache_result = repo
664        .search_cache_only(&query, options.clone())
665        .await
666        .unwrap_or_else(|e| {
667            debug!("[Search] Cache search miss/timeout: {:?}", e);
668            SearchResult {
669                items: Vec::new(),
670                total_record_count: 0,
671            }
672        });
673
674    // Neither backend orders by *where* the query matched, so a mid-word hit
675    // ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
676    // Both phases are ranked with the same rules so the list does not reshuffle
677    // when the server results land.
678    rank_search_results(&mut cache_result.items, &query);
679
680    // Phase 2: query the live server in the background, merge with the cache,
681    // and push the union to the frontend via a `search-event`. Tagged with
682    // `request_id` so the frontend can discard results from superseded queries.
683    let repo_bg = repo.clone();
684    let cache_for_merge = cache_result.clone();
685    tauri::async_runtime::spawn(async move {
686        match repo_bg.search_server_only(&query, options).await {
687            Ok(server_result) => {
688                let mut merged =
689                    HybridRepository::merge_search_results(cache_for_merge, server_result);
690                // Rank the union, not each half: a server-only prefix match must
691                // be able to outrank a cached mid-word one.
692                rank_search_results(&mut merged.items, &query);
693                let event = SearchUpdateEvent {
694                    request_id,
695                    result: merged,
696                };
697                if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
698                    error!("[Search] Failed to emit search update: {}", e);
699                }
700            }
701            Err(e) => {
702                // Server failed — the cache results are already on screen, so
703                // just log. (Offline / unreachable server falls here.)
704                warn!(
705                    "[Search] Server search failed, keeping cache results: {:?}",
706                    e
707                );
708            }
709        }
710    });
711
712    Ok(cache_result)
713}
714
715/// Get playback info for an item
716#[tauri::command]
717#[specta::specta]
718pub async fn repository_get_playback_info(
719    manager: State<'_, RepositoryManagerWrapper>,
720    handle: String,
721    item_id: String,
722) -> Result<PlaybackInfo, String> {
723    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
724    repo.as_ref()
725        .get_playback_info(&item_id)
726        .await
727        .map_err(|e| format!("{:?}", e))
728}
729
730/// Get a video stream URL.
731///
732/// There is no start-position parameter on purpose: the URL is an HLS playlist
733/// covering the whole item, and a position on it makes the server reject every
734/// segment with `400` (DR-181). Callers resume by seeking after load.
735///
736/// TRACES: UR-004 | DR-181 | UT-182
737#[tauri::command]
738#[specta::specta]
739pub async fn repository_get_video_stream_url(
740    manager: State<'_, RepositoryManagerWrapper>,
741    handle: String,
742    item_id: String,
743    media_source_id: Option<String>,
744    audio_stream_index: Option<i32>,
745) -> Result<String, String> {
746    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
747    repo.as_ref()
748        .get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
749        .await
750        .map_err(|e| format!("{:?}", e))
751}
752
753/// Decide what stream to play for a video, and describe it.
754///
755/// Replaces `repository_get_video_stream_url` for playback. The returned
756/// [`StreamSelection`] carries the transport explicitly, so the frontend picks
757/// its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
758/// carries the quality ladder as it applies to *this* source, so the picker can
759/// stop offering rungs that produce the same bytes as Original.
760///
761/// No start-position parameter, for the same reason as the URL builder: a
762/// position on an HLS playlist is copied onto every segment URI and the server
763/// rejects each with `400` (DR-181). Callers resume by seeking after load.
764///
765/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
766#[tauri::command]
767#[specta::specta]
768pub async fn repository_get_stream_selection(
769    manager: State<'_, RepositoryManagerWrapper>,
770    handle: String,
771    item_id: String,
772    media_source_id: Option<String>,
773    audio_stream_index: Option<i32>,
774) -> Result<StreamSelection, String> {
775    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
776    repo.as_ref()
777        .get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
778        .await
779        .map_err(|e| format!("{:?}", e))
780}
781
782/// Get an audio-only stream URL for a *video* item (background-audio handoff).
783///
784/// TRACES: UR-040 | JA-032 | UT-061
785#[tauri::command]
786#[specta::specta]
787pub async fn repository_get_audio_only_stream_url_for_video(
788    manager: State<'_, RepositoryManagerWrapper>,
789    handle: String,
790    item_id: String,
791    media_source_id: Option<String>,
792    start_time_seconds: Option<f64>,
793    audio_stream_index: Option<i32>,
794) -> Result<String, String> {
795    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
796    repo.as_ref()
797        .get_audio_only_stream_url_for_video(
798            &item_id,
799            media_source_id.as_deref(),
800            start_time_seconds,
801            audio_stream_index,
802        )
803        .await
804        .map_err(|e| format!("{:?}", e))
805}
806
807/// Get audio stream URL for a track
808#[tauri::command]
809#[specta::specta]
810pub async fn repository_get_audio_stream_url(
811    manager: State<'_, RepositoryManagerWrapper>,
812    handle: String,
813    item_id: String,
814) -> Result<String, String> {
815    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
816    repo.as_ref()
817        .get_audio_stream_url(&item_id)
818        .await
819        .map_err(|e| format!("{:?}", e))
820}
821
822/// Get Live TV channels (broadcast / IPTV) for browsing
823#[tauri::command]
824#[specta::specta]
825pub async fn repository_get_live_tv_channels(
826    manager: State<'_, RepositoryManagerWrapper>,
827    handle: String,
828) -> Result<Vec<MediaItem>, String> {
829    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
830    repo.as_ref()
831        .get_live_tv_channels()
832        .await
833        .map_err(|e| format!("{:?}", e))
834}
835
836/// Get the root list of plugin "Channels"
837#[tauri::command]
838#[specta::specta]
839pub async fn repository_get_channels(
840    manager: State<'_, RepositoryManagerWrapper>,
841    handle: String,
842) -> Result<SearchResult, String> {
843    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
844    repo.as_ref()
845        .get_channels()
846        .await
847        .map_err(|e| format!("{:?}", e))
848}
849
850/// Open a live stream for a Live TV channel / live item
851#[tauri::command]
852#[specta::specta]
853pub async fn repository_open_live_stream(
854    manager: State<'_, RepositoryManagerWrapper>,
855    handle: String,
856    item_id: String,
857) -> Result<LiveStreamInfo, String> {
858    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
859    repo.as_ref()
860        .open_live_stream(&item_id)
861        .await
862        .map_err(|e| format!("{:?}", e))
863}
864
865/// Report playback start
866#[tauri::command]
867#[specta::specta]
868pub async fn repository_report_playback_start(
869    manager: State<'_, RepositoryManagerWrapper>,
870    handle: String,
871    item_id: String,
872    position_ms: i64,
873) -> Result<(), String> {
874    let position_ticks = position_ms * 10_000;
875    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
876    repo.as_ref()
877        .report_playback_start(&item_id, position_ticks)
878        .await
879        .map_err(|e| format!("{:?}", e))
880}
881
882/// Report playback progress
883#[tauri::command]
884#[specta::specta]
885pub async fn repository_report_playback_progress(
886    manager: State<'_, RepositoryManagerWrapper>,
887    handle: String,
888    item_id: String,
889    position_ms: i64,
890) -> Result<(), String> {
891    let position_ticks = position_ms * 10_000;
892    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
893    repo.as_ref()
894        .report_playback_progress(&item_id, position_ticks)
895        .await
896        .map_err(|e| format!("{:?}", e))
897}
898
899/// Report playback stopped
900///
901/// A stop-report that cannot reach the server is queued rather than dropped:
902/// this is the position the resume point is built from, and losing it is
903/// exactly the "it forgot where I was" the sync queue exists to prevent. The
904/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
905/// failing the command because the *queue* write failed would tell the caller
906/// the report was lost when the local position was already saved.
907///
908/// TRACES: UR-025 | DR-154 | UT-151
909#[tauri::command]
910#[specta::specta]
911pub async fn repository_report_playback_stopped(
912    db: State<'_, crate::commands::storage::DatabaseWrapper>,
913    manager: State<'_, RepositoryManagerWrapper>,
914    handle: String,
915    item_id: String,
916    position_ms: i64,
917) -> Result<(), String> {
918    // Milliseconds across the boundary; the Jellyfin API wants ticks.
919    let position_ticks = position_ms * 10_000;
920    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
921
922    let result = repo
923        .as_ref()
924        .report_playback_stopped(&item_id, position_ticks)
925        .await;
926
927    if let Err(e) = &result {
928        let db_service = {
929            let database = db.0.lock().map_err(|err| err.to_string())?;
930            Arc::new(database.service())
931        };
932        let user_id = repo.user_id().to_string();
933        if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
934            &db_service,
935            &user_id,
936            &item_id,
937            position_ticks,
938        )
939        .await
940        {
941            warn!(
942                "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
943                item_id, e, queue_err
944            );
945        } else {
946            debug!(
947                "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
948                item_id, e
949            );
950        }
951    }
952
953    result.map_err(|e| format!("{:?}", e))
954}
955
956/// Get image URL for an item
957#[tauri::command]
958#[specta::specta]
959pub fn repository_get_image_url(
960    manager: State<'_, RepositoryManagerWrapper>,
961    handle: String,
962    item_id: String,
963    image_type: ImageType,
964    options: Option<ImageOptions>,
965) -> Result<String, String> {
966    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
967    Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
968}
969
970/// Get subtitle URL for a media item
971#[tauri::command]
972#[specta::specta]
973#[allow(dead_code)]
974pub fn repository_get_subtitle_url(
975    manager: State<'_, RepositoryManagerWrapper>,
976    handle: String,
977    item_id: String,
978    media_source_id: String,
979    stream_index: i32,
980    format: String,
981) -> Result<String, String> {
982    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
983    Ok(repo
984        .as_ref()
985        .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
986}
987
988/// Get video download URL with quality preset
989#[tauri::command]
990#[specta::specta]
991#[allow(dead_code)]
992pub async fn repository_get_video_download_url(
993    manager: State<'_, RepositoryManagerWrapper>,
994    handle: String,
995    item_id: String,
996    quality: String,
997    media_source_id: Option<String>,
998) -> Result<String, String> {
999    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1000    // Async because the audio-codec policy has to know what the source's audio
1001    // is before it can decide whether the file may be copied verbatim (DR-171).
1002    // The frontend calls this exactly as before — the decision stays in Rust.
1003    Ok(crate::repository::resolve_video_download_url(
1004        repo.as_ref(),
1005        &item_id,
1006        &quality,
1007        media_source_id.as_deref(),
1008    )
1009    .await)
1010}
1011
1012/// Mark an item as favorite
1013#[tauri::command]
1014#[specta::specta]
1015pub async fn repository_mark_favorite(
1016    manager: State<'_, RepositoryManagerWrapper>,
1017    handle: String,
1018    item_id: String,
1019) -> Result<(), String> {
1020    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1021    repo.as_ref()
1022        .mark_favorite(&item_id)
1023        .await
1024        .map_err(|e| format!("{:?}", e))
1025}
1026
1027/// Tauri event announcing that favourite state changed behind the UI's back —
1028/// either because the server disagreed with the cache on a background refresh,
1029/// or because pending offline toggles were pushed on reconnect.
1030///
1031/// TRACES: UR-069 | DR-120
1032pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
1033
1034/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
1035/// actually flipped, so the frontend refreshes those rather than everything.
1036///
1037/// TRACES: UR-069 | DR-120 | UT-107
1038#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
1039#[serde(rename_all = "camelCase")]
1040pub struct FavoritesChangedEvent {
1041    pub item_ids: Vec<String>,
1042}
1043
1044/// Ids whose favourite state differs between what we showed and what the server
1045/// has — favourited elsewhere since the cache was written, or un-favourited
1046/// elsewhere.
1047///
1048/// Pulled out of the command so the "emit nothing when nothing changed" rule is
1049/// testable: an unchanged set must leave a quiet page quiet rather than
1050/// triggering a refetch on every visit.
1051///
1052/// TRACES: UR-069 | DR-120 | UT-107
1053fn changed_favorite_ids(
1054    cached: &std::collections::HashSet<String>,
1055    server: &std::collections::HashSet<String>,
1056) -> Vec<String> {
1057    let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
1058    // Deterministic order so the event payload does not depend on hash seeding.
1059    changed.sort();
1060    changed
1061}
1062
1063/// Everything the viewer has favourited, across libraries, narrowed by scope.
1064///
1065/// Two-phase like `repository_search`: the local answer returns immediately and
1066/// a background server pass emits `favorites-changed` when the server's set
1067/// differs. Without the second phase a favourite marked in another client shows
1068/// up only on the *second* visit to the page, since the cache-first read hands
1069/// back local rows and the refresh is invisible to the frontend.
1070///
1071/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
1072#[tauri::command]
1073#[specta::specta]
1074pub async fn repository_get_favorites(
1075    app: AppHandle,
1076    manager: State<'_, RepositoryManagerWrapper>,
1077    handle: String,
1078    scope: SearchScope,
1079    options: Option<GetItemsOptions>,
1080) -> Result<SearchResult, String> {
1081    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1082
1083    let cache_result = repo
1084        .get_favorites_cache_only(scope, options.clone())
1085        .await
1086        .unwrap_or_else(|e| {
1087            debug!("[Favorites] Cache miss/timeout: {:?}", e);
1088            SearchResult {
1089                items: Vec::new(),
1090                total_record_count: 0,
1091            }
1092        });
1093
1094    // With "Show all server media" off the local answer is authoritative
1095    // (DR-080) — don't go behind the user's back to the server.
1096    if !crate::repository::offline::include_catalog_browse() {
1097        return Ok(cache_result);
1098    }
1099
1100    // Nothing cached yet — a fresh install, or a viewer whose favourites were
1101    // all marked on another client. Returning the empty result here paints
1102    // "Nothing favourited yet — tap the heart on anything you like", which is a
1103    // *wrong* answer, corrected a server round trip later when the background
1104    // refresh fires `favorites-changed`. Ask the repository for a real answer
1105    // instead: its `get_favorites` is exactly this read — cache first, server on
1106    // a miss, saving through — and it applies the same DR-080 gate.
1107    //
1108    // TRACES: UR-067 | DR-115
1109    if !cache_result.has_content() {
1110        debug!("[Favorites] Nothing cached; answering from the server");
1111        return repo
1112            .get_favorites(scope, options)
1113            .await
1114            .map_err(|e| format!("{:?}", e));
1115    }
1116
1117    let repo_bg = repo.clone();
1118    let cached_ids: std::collections::HashSet<String> =
1119        cache_result.items.iter().map(|i| i.id.clone()).collect();
1120    tauri::async_runtime::spawn(async move {
1121        match repo_bg.get_favorites_server_only(scope, options).await {
1122            Ok(server_result) => {
1123                let server_ids: std::collections::HashSet<String> =
1124                    server_result.items.iter().map(|i| i.id.clone()).collect();
1125                let changed = changed_favorite_ids(&cached_ids, &server_ids);
1126
1127                if !changed.is_empty() {
1128                    let event = FavoritesChangedEvent { item_ids: changed };
1129                    if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
1130                        error!("[Favorites] Failed to emit change event: {}", e);
1131                    }
1132                }
1133            }
1134            Err(e) => {
1135                warn!(
1136                    "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
1137                    e
1138                );
1139            }
1140        }
1141    });
1142
1143    Ok(cache_result)
1144}
1145
1146/// Unmark an item as favorite
1147#[tauri::command]
1148#[specta::specta]
1149pub async fn repository_unmark_favorite(
1150    manager: State<'_, RepositoryManagerWrapper>,
1151    handle: String,
1152    item_id: String,
1153) -> Result<(), String> {
1154    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1155    repo.as_ref()
1156        .unmark_favorite(&item_id)
1157        .await
1158        .map_err(|e| format!("{:?}", e))
1159}
1160
1161/// Get person details
1162#[tauri::command]
1163#[specta::specta]
1164pub async fn repository_get_person(
1165    manager: State<'_, RepositoryManagerWrapper>,
1166    handle: String,
1167    person_id: String,
1168) -> Result<MediaItem, String> {
1169    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1170    repo.as_ref()
1171        .get_person(&person_id)
1172        .await
1173        .map_err(|e| format!("{:?}", e))
1174}
1175
1176/// Get items by person (actor, director, etc.)
1177#[tauri::command]
1178#[specta::specta]
1179pub async fn repository_get_items_by_person(
1180    manager: State<'_, RepositoryManagerWrapper>,
1181    handle: String,
1182    person_id: String,
1183    options: Option<GetItemsOptions>,
1184) -> Result<SearchResult, String> {
1185    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1186    repo.as_ref()
1187        .get_items_by_person(&person_id, options)
1188        .await
1189        .map_err(|e| format!("{:?}", e))
1190}
1191
1192/// Get similar/related items for a media item
1193#[tauri::command]
1194#[specta::specta]
1195pub async fn repository_get_similar_items(
1196    manager: State<'_, RepositoryManagerWrapper>,
1197    handle: String,
1198    item_id: String,
1199    limit: Option<usize>,
1200) -> Result<SearchResult, String> {
1201    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1202    repo.as_ref()
1203        .get_similar_items(&item_id, limit)
1204        .await
1205        .map_err(|e| format!("{:?}", e))
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::*;
1211
1212    #[test]
1213    fn test_repository_manager_creation() {
1214        let manager = RepositoryManager::new();
1215        // A freshly created manager holds no repositories
1216        assert!(manager.get("any-handle").is_none());
1217    }
1218
1219    fn ids(values: &[&str]) -> std::collections::HashSet<String> {
1220        values.iter().map(|v| v.to_string()).collect()
1221    }
1222
1223    /// UT-107 — the background refresh reports only what actually changed.
1224    ///
1225    /// TRACES: UR-069 | DR-120 | UT-107
1226    #[test]
1227    fn test_changed_favorite_ids_reports_both_directions() {
1228        // Favourited in another client since we cached.
1229        assert_eq!(
1230            changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
1231            vec!["b".to_string()]
1232        );
1233
1234        // Un-favourited in another client.
1235        assert_eq!(
1236            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
1237            vec!["b".to_string()]
1238        );
1239
1240        // Both at once, in a stable order.
1241        assert_eq!(
1242            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
1243            vec!["a".to_string(), "c".to_string()]
1244        );
1245    }
1246
1247    /// An unchanged set emits nothing — otherwise every visit to the page would
1248    /// fire an event and trigger a pointless refetch.
1249    ///
1250    /// TRACES: UR-069 | DR-120 | UT-107
1251    #[test]
1252    fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
1253        assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
1254        assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
1255    }
1256
1257    #[test]
1258    fn test_repository_manager_wrapper_structure() {
1259        let manager = RepositoryManager::new();
1260        let wrapper = RepositoryManagerWrapper(manager);
1261        // The wrapper exposes the underlying manager, which starts empty
1262        assert!(wrapper.0.get("any-handle").is_none());
1263    }
1264
1265    #[test]
1266    fn test_repository_manager_get_nonexistent() {
1267        let manager = RepositoryManager::new();
1268        // Getting a non-existent repository should return None
1269        let result = manager.get("nonexistent-handle");
1270        assert!(result.is_none());
1271    }
1272
1273    #[test]
1274    fn test_uuid_handle_generation() {
1275        let uuid = Uuid::new_v4();
1276        let handle = format!("{}", uuid);
1277        // UUID should convert to a non-empty string
1278        assert!(!handle.is_empty());
1279    }
1280
1281    #[test]
1282    fn test_uuid_handles_are_unique() {
1283        let handle1 = format!("{}", Uuid::new_v4());
1284        let handle2 = format!("{}", Uuid::new_v4());
1285        // Two generated UUIDs should be different
1286        assert_ne!(handle1, handle2);
1287    }
1288
1289    #[test]
1290    fn test_uuid_handle_format() {
1291        let uuid = Uuid::new_v4();
1292        let handle = format!("{}", uuid);
1293        // UUID should have standard format with hyphens
1294        let parts: Vec<&str> = handle.split('-').collect();
1295        assert_eq!(parts.len(), 5);
1296    }
1297
1298    #[test]
1299    fn test_repository_manager_destroy_nonexistent() {
1300        let manager = RepositoryManager::new();
1301        // Destroying a non-existent repository should not panic
1302        manager.destroy("nonexistent-handle");
1303    }
1304
1305    #[test]
1306    fn test_repository_manager_is_send_sync() {
1307        // Verify RepositoryManager can be used in async contexts
1308        fn is_send_sync<T: Send + Sync>() {}
1309        is_send_sync::<RepositoryManager>();
1310    }
1311
1312    #[test]
1313    fn test_repository_manager_wrapper_is_send_sync() {
1314        // Verify RepositoryManagerWrapper is Send + Sync
1315        fn is_send_sync<T: Send + Sync>() {}
1316        is_send_sync::<RepositoryManagerWrapper>();
1317    }
1318
1319    #[test]
1320    fn test_multiple_manager_instances() {
1321        let manager1 = RepositoryManager::new();
1322        let manager2 = RepositoryManager::new();
1323
1324        // Multiple manager instances should be independent
1325        let handle1_nonexistent = manager1.get("test");
1326        let handle2_nonexistent = manager2.get("test");
1327
1328        assert!(handle1_nonexistent.is_none());
1329        assert!(handle2_nonexistent.is_none());
1330    }
1331
1332    #[test]
1333    fn test_handle_string_properties() {
1334        let uuid = Uuid::new_v4();
1335        let handle = format!("{}", uuid);
1336
1337        // Handle should be alphanumeric with hyphens
1338        for c in handle.chars() {
1339            assert!(c.is_alphanumeric() || c == '-');
1340        }
1341    }
1342
1343    #[test]
1344    fn test_repository_manager_concurrent_access() {
1345        let manager = Arc::new(RepositoryManager::new());
1346        let mut handles = vec![];
1347
1348        // Verify manager can be wrapped in Arc for concurrent access
1349        for _ in 0..3 {
1350            let mgr = Arc::clone(&manager);
1351            let handle = std::thread::spawn(move || {
1352                let result = mgr.get("test");
1353                assert!(result.is_none());
1354            });
1355            handles.push(handle);
1356        }
1357
1358        for h in handles {
1359            h.join().unwrap();
1360        }
1361    }
1362}
1363
1364#[cfg(test)]
1365mod generation_change_tests {
1366    use super::*;
1367    use crate::repository::capabilities::ServerGeneration;
1368    use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
1369
1370    async fn db_with_server() -> Arc<RusqliteService> {
1371        let conn = rusqlite::Connection::open_in_memory().expect("in-memory db");
1372        for (_, sql) in crate::storage::schema::MIGRATIONS {
1373            conn.execute_batch(sql).expect("migration");
1374        }
1375        let db = Arc::new(RusqliteService::new(Arc::new(std::sync::Mutex::new(conn))));
1376        db.execute(Query::with_params(
1377            "INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
1378            vec![
1379                QueryParam::String("srv-1".into()),
1380                QueryParam::String("Home".into()),
1381                QueryParam::String("https://example.test".into()),
1382                QueryParam::String("10.11.5".into()),
1383            ],
1384        ))
1385        .await
1386        .expect("seed server");
1387        db
1388    }
1389
1390    async fn recorded(db: &Arc<RusqliteService>) -> Option<String> {
1391        db.query_one(
1392            Query::new("SELECT catalog_generation FROM servers WHERE id = 'srv-1'"),
1393            |row| row.get::<_, Option<String>>(0),
1394        )
1395        .await
1396        .ok()
1397        .flatten()
1398    }
1399
1400    /// The first look records the generation and invalidates nothing. A NULL
1401    /// column means "never recorded", not "changed" — treating it as a change
1402    /// would charge every existing user a full re-fetch on upgrade.
1403    ///
1404    /// TRACES: UR-085 | DR-284
1405    #[tokio::test]
1406    async fn the_first_look_records_without_invalidating() {
1407        let db = db_with_server().await;
1408        let invalidated =
1409            invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1410
1411        assert!(!invalidated, "a first sighting is not a change");
1412        assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
1413    }
1414
1415    /// Seeing the same generation again is not a change either.
1416    ///
1417    /// TRACES: UR-085 | DR-284
1418    #[tokio::test]
1419    async fn an_unchanged_generation_does_not_invalidate() {
1420        let db = db_with_server().await;
1421        invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1422        let invalidated =
1423            invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1424
1425        assert!(!invalidated);
1426        assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
1427    }
1428
1429    /// An actual upgrade drops the cached catalog and records the new
1430    /// generation, so the next browse re-fetches under the new shapes.
1431    ///
1432    /// TRACES: UR-085 | DR-284
1433    #[tokio::test]
1434    async fn a_real_upgrade_invalidates_and_records() {
1435        let db = db_with_server().await;
1436        invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1437
1438        let invalidated =
1439            invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V12Plus).await;
1440
1441        assert!(invalidated, "10.11 -> 12.x is a generation change");
1442        assert_eq!(recorded(&db).await.as_deref(), Some("V12Plus"));
1443    }
1444
1445    /// A server row that is missing entirely must not panic or invalidate.
1446    ///
1447    /// TRACES: UR-085 | DR-284
1448    #[tokio::test]
1449    async fn an_unknown_server_is_harmless() {
1450        let db = db_with_server().await;
1451        let invalidated =
1452            invalidate_cache_on_generation_change(&db, "no-such-server", ServerGeneration::V12Plus)
1453                .await;
1454        assert!(!invalidated);
1455    }
1456}