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/// Erase the viewer's watch history for an item.
518///
519/// Clears the played flag and the resume position; on a series or season the
520/// server applies it to everything inside. A series cleared this way is "never
521/// watched" again, so `repository_get_series_current_episode` returns its
522/// premiere. Requires the server — offline this fails rather than diverging
523/// local state the next sync would overwrite.
524///
525/// TRACES: UR-064 | DR-106
526#[tauri::command]
527#[specta::specta]
528pub async fn repository_clear_watch_history(
529    manager: State<'_, RepositoryManagerWrapper>,
530    handle: String,
531    item_id: String,
532) -> Result<(), String> {
533    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
534    repo.as_ref()
535        .clear_watch_history(&item_id)
536        .await
537        .map_err(|e| format!("{:?}", e))
538}
539
540/// Get recently played audio
541#[tauri::command]
542#[specta::specta]
543pub async fn repository_get_recently_played_audio(
544    manager: State<'_, RepositoryManagerWrapper>,
545    handle: String,
546    limit: Option<usize>,
547) -> Result<Vec<MediaItem>, String> {
548    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
549    repo.as_ref()
550        .get_recently_played_audio(limit)
551        .await
552        .map_err(|e| format!("{:?}", e))
553}
554
555/// Get resume movies
556#[tauri::command]
557#[specta::specta]
558pub async fn repository_get_resume_movies(
559    manager: State<'_, RepositoryManagerWrapper>,
560    handle: String,
561    limit: Option<usize>,
562) -> Result<Vec<MediaItem>, String> {
563    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
564    repo.as_ref()
565        .get_resume_movies(limit)
566        .await
567        .map_err(|e| format!("{:?}", e))
568}
569
570/// Get albums the user hasn't listened to recently ("rediscover")
571#[tauri::command]
572#[specta::specta]
573pub async fn repository_get_rediscover_albums(
574    manager: State<'_, RepositoryManagerWrapper>,
575    handle: String,
576    parent_id: Option<String>,
577    limit: Option<usize>,
578) -> Result<Vec<MediaItem>, String> {
579    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
580    repo.as_ref()
581        .get_rediscover_albums(parent_id.as_deref(), limit)
582        .await
583        .map_err(|e| format!("{:?}", e))
584}
585
586/// Get genres for a library
587#[tauri::command]
588#[specta::specta]
589pub async fn repository_get_genres(
590    manager: State<'_, RepositoryManagerWrapper>,
591    handle: String,
592    parent_id: Option<String>,
593) -> Result<Vec<Genre>, String> {
594    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
595    repo.as_ref()
596        .get_genres(parent_id.as_deref())
597        .await
598        .map_err(|e| format!("{:?}", e))
599}
600
601/// Tauri event name carrying the merged (cache + server) search results.
602pub const SEARCH_EVENT_NAME: &str = "search-event";
603
604/// Payload for the deferred, merged search results pushed to the frontend.
605///
606/// `request_id` matches the value the frontend passed to `repository_search`,
607/// letting it discard updates from queries that have since been superseded.
608#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
609#[serde(rename_all = "camelCase")]
610pub struct SearchUpdateEvent {
611    pub request_id: u32,
612    pub result: SearchResult,
613}
614
615/// Search for items.
616///
617/// Resolves `SearchOptions::scope` into concrete Jellyfin item types before
618/// dispatching, so scope taxonomy stays in Rust.
619///
620/// TRACES: UR-049, UR-050 | DR-063
621#[tauri::command]
622#[specta::specta]
623pub async fn repository_search(
624    app: AppHandle,
625    manager: State<'_, RepositoryManagerWrapper>,
626    handle: String,
627    query: String,
628    options: Option<SearchOptions>,
629    request_id: u32,
630) -> Result<SearchResult, String> {
631    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
632
633    // Expand the opaque scope into item types HERE — once, before the cache and
634    // server paths diverge — so both phases filter identically. Doing it later
635    // (or in only one path) makes offline results disagree with online ones.
636    // The frontend sends `scope` and never names a Jellyfin item type for
637    // search; see docs/specs/scoped-search-boundary.md.
638    let options = options.map(|mut o| {
639        o.resolve_scope();
640        o
641    });
642
643    // Phase 1: instant local results from the cache (downloaded content) so the
644    // UI can render immediately while the server is still being queried.
645    let mut cache_result = repo
646        .search_cache_only(&query, options.clone())
647        .await
648        .unwrap_or_else(|e| {
649            debug!("[Search] Cache search miss/timeout: {:?}", e);
650            SearchResult {
651                items: Vec::new(),
652                total_record_count: 0,
653            }
654        });
655
656    // Neither backend orders by *where* the query matched, so a mid-word hit
657    // ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
658    // Both phases are ranked with the same rules so the list does not reshuffle
659    // when the server results land.
660    rank_search_results(&mut cache_result.items, &query);
661
662    // Phase 2: query the live server in the background, merge with the cache,
663    // and push the union to the frontend via a `search-event`. Tagged with
664    // `request_id` so the frontend can discard results from superseded queries.
665    let repo_bg = repo.clone();
666    let cache_for_merge = cache_result.clone();
667    tauri::async_runtime::spawn(async move {
668        match repo_bg.search_server_only(&query, options).await {
669            Ok(server_result) => {
670                let mut merged =
671                    HybridRepository::merge_search_results(cache_for_merge, server_result);
672                // Rank the union, not each half: a server-only prefix match must
673                // be able to outrank a cached mid-word one.
674                rank_search_results(&mut merged.items, &query);
675                let event = SearchUpdateEvent {
676                    request_id,
677                    result: merged,
678                };
679                if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
680                    error!("[Search] Failed to emit search update: {}", e);
681                }
682            }
683            Err(e) => {
684                // Server failed — the cache results are already on screen, so
685                // just log. (Offline / unreachable server falls here.)
686                warn!(
687                    "[Search] Server search failed, keeping cache results: {:?}",
688                    e
689                );
690            }
691        }
692    });
693
694    Ok(cache_result)
695}
696
697/// Get playback info for an item
698#[tauri::command]
699#[specta::specta]
700pub async fn repository_get_playback_info(
701    manager: State<'_, RepositoryManagerWrapper>,
702    handle: String,
703    item_id: String,
704) -> Result<PlaybackInfo, String> {
705    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
706    repo.as_ref()
707        .get_playback_info(&item_id)
708        .await
709        .map_err(|e| format!("{:?}", e))
710}
711
712/// Get a video stream URL.
713///
714/// There is no start-position parameter on purpose: the URL is an HLS playlist
715/// covering the whole item, and a position on it makes the server reject every
716/// segment with `400` (DR-181). Callers resume by seeking after load.
717///
718/// TRACES: UR-004 | DR-181 | UT-182
719#[tauri::command]
720#[specta::specta]
721pub async fn repository_get_video_stream_url(
722    manager: State<'_, RepositoryManagerWrapper>,
723    handle: String,
724    item_id: String,
725    media_source_id: Option<String>,
726    audio_stream_index: Option<i32>,
727) -> Result<String, String> {
728    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
729    repo.as_ref()
730        .get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
731        .await
732        .map_err(|e| format!("{:?}", e))
733}
734
735/// Decide what stream to play for a video, and describe it.
736///
737/// Replaces `repository_get_video_stream_url` for playback. The returned
738/// [`StreamSelection`] carries the transport explicitly, so the frontend picks
739/// its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
740/// carries the quality ladder as it applies to *this* source, so the picker can
741/// stop offering rungs that produce the same bytes as Original.
742///
743/// No start-position parameter, for the same reason as the URL builder: a
744/// position on an HLS playlist is copied onto every segment URI and the server
745/// rejects each with `400` (DR-181). Callers resume by seeking after load.
746///
747/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
748#[tauri::command]
749#[specta::specta]
750pub async fn repository_get_stream_selection(
751    manager: State<'_, RepositoryManagerWrapper>,
752    handle: String,
753    item_id: String,
754    media_source_id: Option<String>,
755    audio_stream_index: Option<i32>,
756) -> Result<StreamSelection, String> {
757    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
758    repo.as_ref()
759        .get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
760        .await
761        .map_err(|e| format!("{:?}", e))
762}
763
764/// Get an audio-only stream URL for a *video* item (background-audio handoff).
765///
766/// TRACES: UR-040 | JA-032 | UT-061
767#[tauri::command]
768#[specta::specta]
769pub async fn repository_get_audio_only_stream_url_for_video(
770    manager: State<'_, RepositoryManagerWrapper>,
771    handle: String,
772    item_id: String,
773    media_source_id: Option<String>,
774    start_time_seconds: Option<f64>,
775    audio_stream_index: Option<i32>,
776) -> Result<String, String> {
777    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
778    repo.as_ref()
779        .get_audio_only_stream_url_for_video(
780            &item_id,
781            media_source_id.as_deref(),
782            start_time_seconds,
783            audio_stream_index,
784        )
785        .await
786        .map_err(|e| format!("{:?}", e))
787}
788
789/// Get audio stream URL for a track
790#[tauri::command]
791#[specta::specta]
792pub async fn repository_get_audio_stream_url(
793    manager: State<'_, RepositoryManagerWrapper>,
794    handle: String,
795    item_id: String,
796) -> Result<String, String> {
797    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
798    repo.as_ref()
799        .get_audio_stream_url(&item_id)
800        .await
801        .map_err(|e| format!("{:?}", e))
802}
803
804/// Get Live TV channels (broadcast / IPTV) for browsing
805#[tauri::command]
806#[specta::specta]
807pub async fn repository_get_live_tv_channels(
808    manager: State<'_, RepositoryManagerWrapper>,
809    handle: String,
810) -> Result<Vec<MediaItem>, String> {
811    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
812    repo.as_ref()
813        .get_live_tv_channels()
814        .await
815        .map_err(|e| format!("{:?}", e))
816}
817
818/// Get the root list of plugin "Channels"
819#[tauri::command]
820#[specta::specta]
821pub async fn repository_get_channels(
822    manager: State<'_, RepositoryManagerWrapper>,
823    handle: String,
824) -> Result<SearchResult, String> {
825    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
826    repo.as_ref()
827        .get_channels()
828        .await
829        .map_err(|e| format!("{:?}", e))
830}
831
832/// Open a live stream for a Live TV channel / live item
833#[tauri::command]
834#[specta::specta]
835pub async fn repository_open_live_stream(
836    manager: State<'_, RepositoryManagerWrapper>,
837    handle: String,
838    item_id: String,
839) -> Result<LiveStreamInfo, String> {
840    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
841    repo.as_ref()
842        .open_live_stream(&item_id)
843        .await
844        .map_err(|e| format!("{:?}", e))
845}
846
847/// Report playback start
848#[tauri::command]
849#[specta::specta]
850pub async fn repository_report_playback_start(
851    manager: State<'_, RepositoryManagerWrapper>,
852    handle: String,
853    item_id: String,
854    position_ms: i64,
855) -> Result<(), String> {
856    let position_ticks = position_ms * 10_000;
857    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
858    repo.as_ref()
859        .report_playback_start(&item_id, position_ticks)
860        .await
861        .map_err(|e| format!("{:?}", e))
862}
863
864/// Report playback progress
865#[tauri::command]
866#[specta::specta]
867pub async fn repository_report_playback_progress(
868    manager: State<'_, RepositoryManagerWrapper>,
869    handle: String,
870    item_id: String,
871    position_ms: i64,
872) -> Result<(), String> {
873    let position_ticks = position_ms * 10_000;
874    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
875    repo.as_ref()
876        .report_playback_progress(&item_id, position_ticks)
877        .await
878        .map_err(|e| format!("{:?}", e))
879}
880
881/// Report playback stopped
882///
883/// A stop-report that cannot reach the server is queued rather than dropped:
884/// this is the position the resume point is built from, and losing it is
885/// exactly the "it forgot where I was" the sync queue exists to prevent. The
886/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
887/// failing the command because the *queue* write failed would tell the caller
888/// the report was lost when the local position was already saved.
889///
890/// TRACES: UR-025 | DR-154 | UT-151
891#[tauri::command]
892#[specta::specta]
893pub async fn repository_report_playback_stopped(
894    db: State<'_, crate::commands::storage::DatabaseWrapper>,
895    manager: State<'_, RepositoryManagerWrapper>,
896    handle: String,
897    item_id: String,
898    position_ms: i64,
899) -> Result<(), String> {
900    // Milliseconds across the boundary; the Jellyfin API wants ticks.
901    let position_ticks = position_ms * 10_000;
902    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
903
904    let result = repo
905        .as_ref()
906        .report_playback_stopped(&item_id, position_ticks)
907        .await;
908
909    if let Err(e) = &result {
910        let db_service = {
911            let database = db.0.lock().map_err(|err| err.to_string())?;
912            Arc::new(database.service())
913        };
914        let user_id = repo.user_id().to_string();
915        if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
916            &db_service,
917            &user_id,
918            &item_id,
919            position_ticks,
920        )
921        .await
922        {
923            warn!(
924                "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
925                item_id, e, queue_err
926            );
927        } else {
928            debug!(
929                "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
930                item_id, e
931            );
932        }
933    }
934
935    result.map_err(|e| format!("{:?}", e))
936}
937
938/// Get image URL for an item
939#[tauri::command]
940#[specta::specta]
941pub fn repository_get_image_url(
942    manager: State<'_, RepositoryManagerWrapper>,
943    handle: String,
944    item_id: String,
945    image_type: ImageType,
946    options: Option<ImageOptions>,
947) -> Result<String, String> {
948    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
949    Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
950}
951
952/// Get subtitle URL for a media item
953#[tauri::command]
954#[specta::specta]
955#[allow(dead_code)]
956pub fn repository_get_subtitle_url(
957    manager: State<'_, RepositoryManagerWrapper>,
958    handle: String,
959    item_id: String,
960    media_source_id: String,
961    stream_index: i32,
962    format: String,
963) -> Result<String, String> {
964    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
965    Ok(repo
966        .as_ref()
967        .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
968}
969
970/// Get video download URL with quality preset
971#[tauri::command]
972#[specta::specta]
973#[allow(dead_code)]
974pub async fn repository_get_video_download_url(
975    manager: State<'_, RepositoryManagerWrapper>,
976    handle: String,
977    item_id: String,
978    quality: String,
979    media_source_id: Option<String>,
980) -> Result<String, String> {
981    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
982    // Async because the audio-codec policy has to know what the source's audio
983    // is before it can decide whether the file may be copied verbatim (DR-171).
984    // The frontend calls this exactly as before — the decision stays in Rust.
985    Ok(crate::repository::resolve_video_download_url(
986        repo.as_ref(),
987        &item_id,
988        &quality,
989        media_source_id.as_deref(),
990    )
991    .await)
992}
993
994/// Mark an item as favorite
995#[tauri::command]
996#[specta::specta]
997pub async fn repository_mark_favorite(
998    manager: State<'_, RepositoryManagerWrapper>,
999    handle: String,
1000    item_id: String,
1001) -> Result<(), String> {
1002    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1003    repo.as_ref()
1004        .mark_favorite(&item_id)
1005        .await
1006        .map_err(|e| format!("{:?}", e))
1007}
1008
1009/// Tauri event announcing that favourite state changed behind the UI's back —
1010/// either because the server disagreed with the cache on a background refresh,
1011/// or because pending offline toggles were pushed on reconnect.
1012///
1013/// TRACES: UR-069 | DR-120
1014pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
1015
1016/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
1017/// actually flipped, so the frontend refreshes those rather than everything.
1018///
1019/// TRACES: UR-069 | DR-120 | UT-107
1020#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
1021#[serde(rename_all = "camelCase")]
1022pub struct FavoritesChangedEvent {
1023    pub item_ids: Vec<String>,
1024}
1025
1026/// Ids whose favourite state differs between what we showed and what the server
1027/// has — favourited elsewhere since the cache was written, or un-favourited
1028/// elsewhere.
1029///
1030/// Pulled out of the command so the "emit nothing when nothing changed" rule is
1031/// testable: an unchanged set must leave a quiet page quiet rather than
1032/// triggering a refetch on every visit.
1033///
1034/// TRACES: UR-069 | DR-120 | UT-107
1035fn changed_favorite_ids(
1036    cached: &std::collections::HashSet<String>,
1037    server: &std::collections::HashSet<String>,
1038) -> Vec<String> {
1039    let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
1040    // Deterministic order so the event payload does not depend on hash seeding.
1041    changed.sort();
1042    changed
1043}
1044
1045/// Everything the viewer has favourited, across libraries, narrowed by scope.
1046///
1047/// Two-phase like `repository_search`: the local answer returns immediately and
1048/// a background server pass emits `favorites-changed` when the server's set
1049/// differs. Without the second phase a favourite marked in another client shows
1050/// up only on the *second* visit to the page, since the cache-first read hands
1051/// back local rows and the refresh is invisible to the frontend.
1052///
1053/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
1054#[tauri::command]
1055#[specta::specta]
1056pub async fn repository_get_favorites(
1057    app: AppHandle,
1058    manager: State<'_, RepositoryManagerWrapper>,
1059    handle: String,
1060    scope: SearchScope,
1061    options: Option<GetItemsOptions>,
1062) -> Result<SearchResult, String> {
1063    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1064
1065    let cache_result = repo
1066        .get_favorites_cache_only(scope, options.clone())
1067        .await
1068        .unwrap_or_else(|e| {
1069            debug!("[Favorites] Cache miss/timeout: {:?}", e);
1070            SearchResult {
1071                items: Vec::new(),
1072                total_record_count: 0,
1073            }
1074        });
1075
1076    // With "Show all server media" off the local answer is authoritative
1077    // (DR-080) — don't go behind the user's back to the server.
1078    if !crate::repository::offline::include_catalog_browse() {
1079        return Ok(cache_result);
1080    }
1081
1082    // Nothing cached yet — a fresh install, or a viewer whose favourites were
1083    // all marked on another client. Returning the empty result here paints
1084    // "Nothing favourited yet — tap the heart on anything you like", which is a
1085    // *wrong* answer, corrected a server round trip later when the background
1086    // refresh fires `favorites-changed`. Ask the repository for a real answer
1087    // instead: its `get_favorites` is exactly this read — cache first, server on
1088    // a miss, saving through — and it applies the same DR-080 gate.
1089    //
1090    // TRACES: UR-067 | DR-115
1091    if !cache_result.has_content() {
1092        debug!("[Favorites] Nothing cached; answering from the server");
1093        return repo
1094            .get_favorites(scope, options)
1095            .await
1096            .map_err(|e| format!("{:?}", e));
1097    }
1098
1099    let repo_bg = repo.clone();
1100    let cached_ids: std::collections::HashSet<String> =
1101        cache_result.items.iter().map(|i| i.id.clone()).collect();
1102    tauri::async_runtime::spawn(async move {
1103        match repo_bg.get_favorites_server_only(scope, options).await {
1104            Ok(server_result) => {
1105                let server_ids: std::collections::HashSet<String> =
1106                    server_result.items.iter().map(|i| i.id.clone()).collect();
1107                let changed = changed_favorite_ids(&cached_ids, &server_ids);
1108
1109                if !changed.is_empty() {
1110                    let event = FavoritesChangedEvent { item_ids: changed };
1111                    if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
1112                        error!("[Favorites] Failed to emit change event: {}", e);
1113                    }
1114                }
1115            }
1116            Err(e) => {
1117                warn!(
1118                    "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
1119                    e
1120                );
1121            }
1122        }
1123    });
1124
1125    Ok(cache_result)
1126}
1127
1128/// Unmark an item as favorite
1129#[tauri::command]
1130#[specta::specta]
1131pub async fn repository_unmark_favorite(
1132    manager: State<'_, RepositoryManagerWrapper>,
1133    handle: String,
1134    item_id: String,
1135) -> Result<(), String> {
1136    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1137    repo.as_ref()
1138        .unmark_favorite(&item_id)
1139        .await
1140        .map_err(|e| format!("{:?}", e))
1141}
1142
1143/// Get person details
1144#[tauri::command]
1145#[specta::specta]
1146pub async fn repository_get_person(
1147    manager: State<'_, RepositoryManagerWrapper>,
1148    handle: String,
1149    person_id: String,
1150) -> Result<MediaItem, String> {
1151    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1152    repo.as_ref()
1153        .get_person(&person_id)
1154        .await
1155        .map_err(|e| format!("{:?}", e))
1156}
1157
1158/// Get items by person (actor, director, etc.)
1159#[tauri::command]
1160#[specta::specta]
1161pub async fn repository_get_items_by_person(
1162    manager: State<'_, RepositoryManagerWrapper>,
1163    handle: String,
1164    person_id: String,
1165    options: Option<GetItemsOptions>,
1166) -> Result<SearchResult, String> {
1167    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1168    repo.as_ref()
1169        .get_items_by_person(&person_id, options)
1170        .await
1171        .map_err(|e| format!("{:?}", e))
1172}
1173
1174/// Get similar/related items for a media item
1175#[tauri::command]
1176#[specta::specta]
1177pub async fn repository_get_similar_items(
1178    manager: State<'_, RepositoryManagerWrapper>,
1179    handle: String,
1180    item_id: String,
1181    limit: Option<usize>,
1182) -> Result<SearchResult, String> {
1183    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
1184    repo.as_ref()
1185        .get_similar_items(&item_id, limit)
1186        .await
1187        .map_err(|e| format!("{:?}", e))
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193
1194    #[test]
1195    fn test_repository_manager_creation() {
1196        let manager = RepositoryManager::new();
1197        // A freshly created manager holds no repositories
1198        assert!(manager.get("any-handle").is_none());
1199    }
1200
1201    fn ids(values: &[&str]) -> std::collections::HashSet<String> {
1202        values.iter().map(|v| v.to_string()).collect()
1203    }
1204
1205    /// UT-107 — the background refresh reports only what actually changed.
1206    ///
1207    /// TRACES: UR-069 | DR-120 | UT-107
1208    #[test]
1209    fn test_changed_favorite_ids_reports_both_directions() {
1210        // Favourited in another client since we cached.
1211        assert_eq!(
1212            changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
1213            vec!["b".to_string()]
1214        );
1215
1216        // Un-favourited in another client.
1217        assert_eq!(
1218            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
1219            vec!["b".to_string()]
1220        );
1221
1222        // Both at once, in a stable order.
1223        assert_eq!(
1224            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
1225            vec!["a".to_string(), "c".to_string()]
1226        );
1227    }
1228
1229    /// An unchanged set emits nothing — otherwise every visit to the page would
1230    /// fire an event and trigger a pointless refetch.
1231    ///
1232    /// TRACES: UR-069 | DR-120 | UT-107
1233    #[test]
1234    fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
1235        assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
1236        assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
1237    }
1238
1239    #[test]
1240    fn test_repository_manager_wrapper_structure() {
1241        let manager = RepositoryManager::new();
1242        let wrapper = RepositoryManagerWrapper(manager);
1243        // The wrapper exposes the underlying manager, which starts empty
1244        assert!(wrapper.0.get("any-handle").is_none());
1245    }
1246
1247    #[test]
1248    fn test_repository_manager_get_nonexistent() {
1249        let manager = RepositoryManager::new();
1250        // Getting a non-existent repository should return None
1251        let result = manager.get("nonexistent-handle");
1252        assert!(result.is_none());
1253    }
1254
1255    #[test]
1256    fn test_uuid_handle_generation() {
1257        let uuid = Uuid::new_v4();
1258        let handle = format!("{}", uuid);
1259        // UUID should convert to a non-empty string
1260        assert!(!handle.is_empty());
1261    }
1262
1263    #[test]
1264    fn test_uuid_handles_are_unique() {
1265        let handle1 = format!("{}", Uuid::new_v4());
1266        let handle2 = format!("{}", Uuid::new_v4());
1267        // Two generated UUIDs should be different
1268        assert_ne!(handle1, handle2);
1269    }
1270
1271    #[test]
1272    fn test_uuid_handle_format() {
1273        let uuid = Uuid::new_v4();
1274        let handle = format!("{}", uuid);
1275        // UUID should have standard format with hyphens
1276        let parts: Vec<&str> = handle.split('-').collect();
1277        assert_eq!(parts.len(), 5);
1278    }
1279
1280    #[test]
1281    fn test_repository_manager_destroy_nonexistent() {
1282        let manager = RepositoryManager::new();
1283        // Destroying a non-existent repository should not panic
1284        manager.destroy("nonexistent-handle");
1285    }
1286
1287    #[test]
1288    fn test_repository_manager_is_send_sync() {
1289        // Verify RepositoryManager can be used in async contexts
1290        fn is_send_sync<T: Send + Sync>() {}
1291        is_send_sync::<RepositoryManager>();
1292    }
1293
1294    #[test]
1295    fn test_repository_manager_wrapper_is_send_sync() {
1296        // Verify RepositoryManagerWrapper is Send + Sync
1297        fn is_send_sync<T: Send + Sync>() {}
1298        is_send_sync::<RepositoryManagerWrapper>();
1299    }
1300
1301    #[test]
1302    fn test_multiple_manager_instances() {
1303        let manager1 = RepositoryManager::new();
1304        let manager2 = RepositoryManager::new();
1305
1306        // Multiple manager instances should be independent
1307        let handle1_nonexistent = manager1.get("test");
1308        let handle2_nonexistent = manager2.get("test");
1309
1310        assert!(handle1_nonexistent.is_none());
1311        assert!(handle2_nonexistent.is_none());
1312    }
1313
1314    #[test]
1315    fn test_handle_string_properties() {
1316        let uuid = Uuid::new_v4();
1317        let handle = format!("{}", uuid);
1318
1319        // Handle should be alphanumeric with hyphens
1320        for c in handle.chars() {
1321            assert!(c.is_alphanumeric() || c == '-');
1322        }
1323    }
1324
1325    #[test]
1326    fn test_repository_manager_concurrent_access() {
1327        let manager = Arc::new(RepositoryManager::new());
1328        let mut handles = vec![];
1329
1330        // Verify manager can be wrapped in Arc for concurrent access
1331        for _ in 0..3 {
1332            let mgr = Arc::clone(&manager);
1333            let handle = std::thread::spawn(move || {
1334                let result = mgr.get("test");
1335                assert!(result.is_none());
1336            });
1337            handles.push(handle);
1338        }
1339
1340        for h in handles {
1341            h.join().unwrap();
1342        }
1343    }
1344}
1345
1346#[cfg(test)]
1347mod generation_change_tests {
1348    use super::*;
1349    use crate::repository::capabilities::ServerGeneration;
1350    use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
1351
1352    async fn db_with_server() -> Arc<RusqliteService> {
1353        let conn = rusqlite::Connection::open_in_memory().expect("in-memory db");
1354        for (_, sql) in crate::storage::schema::MIGRATIONS {
1355            conn.execute_batch(sql).expect("migration");
1356        }
1357        let db = Arc::new(RusqliteService::new(Arc::new(std::sync::Mutex::new(conn))));
1358        db.execute(Query::with_params(
1359            "INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
1360            vec![
1361                QueryParam::String("srv-1".into()),
1362                QueryParam::String("Home".into()),
1363                QueryParam::String("https://example.test".into()),
1364                QueryParam::String("10.11.5".into()),
1365            ],
1366        ))
1367        .await
1368        .expect("seed server");
1369        db
1370    }
1371
1372    async fn recorded(db: &Arc<RusqliteService>) -> Option<String> {
1373        db.query_one(
1374            Query::new("SELECT catalog_generation FROM servers WHERE id = 'srv-1'"),
1375            |row| row.get::<_, Option<String>>(0),
1376        )
1377        .await
1378        .ok()
1379        .flatten()
1380    }
1381
1382    /// The first look records the generation and invalidates nothing. A NULL
1383    /// column means "never recorded", not "changed" — treating it as a change
1384    /// would charge every existing user a full re-fetch on upgrade.
1385    ///
1386    /// TRACES: UR-085 | DR-284
1387    #[tokio::test]
1388    async fn the_first_look_records_without_invalidating() {
1389        let db = db_with_server().await;
1390        let invalidated =
1391            invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1392
1393        assert!(!invalidated, "a first sighting is not a change");
1394        assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
1395    }
1396
1397    /// Seeing the same generation again is not a change either.
1398    ///
1399    /// TRACES: UR-085 | DR-284
1400    #[tokio::test]
1401    async fn an_unchanged_generation_does_not_invalidate() {
1402        let db = db_with_server().await;
1403        invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1404        let invalidated =
1405            invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1406
1407        assert!(!invalidated);
1408        assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
1409    }
1410
1411    /// An actual upgrade drops the cached catalog and records the new
1412    /// generation, so the next browse re-fetches under the new shapes.
1413    ///
1414    /// TRACES: UR-085 | DR-284
1415    #[tokio::test]
1416    async fn a_real_upgrade_invalidates_and_records() {
1417        let db = db_with_server().await;
1418        invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
1419
1420        let invalidated =
1421            invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V12Plus).await;
1422
1423        assert!(invalidated, "10.11 -> 12.x is a generation change");
1424        assert_eq!(recorded(&db).await.as_deref(), Some("V12Plus"));
1425    }
1426
1427    /// A server row that is missing entirely must not panic or invalidate.
1428    ///
1429    /// TRACES: UR-085 | DR-284
1430    #[tokio::test]
1431    async fn an_unknown_server_is_harmless() {
1432        let db = db_with_server().await;
1433        let invalidated =
1434            invalidate_cache_on_generation_change(&db, "no-such-server", ServerGeneration::V12Plus)
1435                .await;
1436        assert!(!invalidated);
1437    }
1438}