Files
jellytau/docs/architecture/01-rust-backend.md
T
dtourolle 32043a2152 docs: fold shipped specs into the architecture docs and delete them
A spec was a promise; sixteen of them had become descriptions of code that
already shipped, sitting beside four that describe work still outstanding, with
nothing in the file telling the two apart. Half the statuses were also wrong —
audio-equalizer read "Accepted" with the EQ live on both platforms, the native
video spec said the flag stays off after the default was flipped on.

The shipped designs move into docs/architecture, which is the maintained
description of the build, and the spec files go. Git history keeps the
originals; what a future change still needs is carried across:

- 01-rust-backend: favourites rewritten (the old section named a file that no
  longer exists and called shipped buttons "planned"), domain vocabulary owned
  by Rust (SearchScope, exclusions, the bitrate ladder), background workers
- 02-svelte-frontend: app shell and chrome, library mosaic, series/episode
  navigation, downloaded browse, safe-area insets, native-video store, logging
- 03-data-flow: locally-indexed search
- 05-platform-backends: audio settings on ExoPlayer, the equalizer's band
  vocabulary, native video compositing, the background-audio handoff
- 06-downloads-and-offline: one storage model, offline catalog visibility
- 09-security: path confinement and input binding

docs/specs/README.md now says what the directory is for and where each shipped
design went. Deferred work the specs recorded is kept beside the code it
concerns rather than lost: season-bounded autoplay, the two dead search
commands, why indexing is a full crawl.

requirements.md had fourteen stale statuses — Android audio parity still read
"Linux only", DR-150 still said the native-video default was off, DR-190 was
Proposed after DR-196 implemented it, and five tooling requirements were
Proposed after landing. Three unbuilt specs suggested requirement ids that have
since been allocated to other work; each now carries a warning.
2026-08-21 18:15:58 +02:00

26 KiB

Rust Backend Architecture

Location: src-tauri/src/

Media Session State Machine

Location: src-tauri/src/player/session.rs

The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.

Architecture Note: The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.

stateDiagram-v2
    [*] --> Idle

    Idle --> AudioActive : play_queue(audio)
    Idle --> MovieActive : play_item(movie)
    Idle --> TvShowActive : play_item(episode)

    state "Audio Session" as AudioSession {
        [*] --> AudioActive
        AudioActive --> AudioInactive : playback_ended
        AudioInactive --> AudioActive : resume/play
        AudioActive --> AudioActive : next/previous
    }

    state "Movie Session" as MovieSession {
        [*] --> MovieActive
        MovieActive --> MovieInactive : playback_ended
        MovieInactive --> MovieActive : resume
    }

    state "TV Show Session" as TvShowSession {
        [*] --> TvShowActive
        TvShowActive --> TvShowInactive : playback_ended
        TvShowInactive --> TvShowActive : next_episode/resume
    }

    AudioSession --> Idle : dismiss/clear_queue
    AudioSession --> MovieSession : play_item(movie)
    AudioSession --> TvShowSession : play_item(episode)

    MovieSession --> Idle : dismiss/playback_complete
    MovieSession --> AudioSession : play_queue(audio)

    TvShowSession --> Idle : dismiss/series_complete
    TvShowSession --> AudioSession : play_queue(audio)

    note right of Idle
        No active media session
        Queue may exist but not playing
        No miniplayer/video player shown
    end note

    note right of AudioSession
        SHOW: Miniplayer (always visible)
        - Active: Play/pause/skip controls enabled
        - Inactive: Play button to resume queue
        Persists until explicit dismiss
    end note

    note right of MovieSession
        SHOW: Full video player
        - Active: Video playing/paused
        - Inactive: Resume dialog
        Auto-dismiss when playback ends
    end note

    note right of TvShowSession
        SHOW: Full video player + Next Episode UI
        - Active: Video playing/paused
        - Inactive: Next episode prompt
        Auto-dismiss when series ends
    end note

Session State Enum:

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MediaSessionType {
    /// No active session - browsing library
    Idle,

    /// Audio playback session (music, audiobooks, podcasts)
    /// Persists until explicitly dismissed
    Audio {
        /// Last/current track being played
        last_item: Option<MediaItem>,
        /// True = playing/paused, False = stopped/ended
        is_active: bool,
    },

    /// Movie playback (single video, auto-dismiss on end)
    Movie {
        item: MediaItem,
        is_active: bool,  // true = playing/paused, false = ended
    },

    /// TV show playback (supports next episode auto-advance)
    TvShow {
        item: MediaItem,
        series_id: String,
        is_active: bool,  // true = playing/paused, false = ended
    },
}

State Transitions & Rules:

From State Event To State UI Behavior Notes
Idle play_queue(audio) Audio (active) Show miniplayer Creates audio session
Idle play_item(movie) Movie (active) Show video player Creates movie session
Idle play_item(episode) TvShow (active) Show video player Creates TV session
Audio (active) playback_ended Audio (inactive) Miniplayer stays visible Queue preserved
Audio (inactive) play/resume Audio (active) Miniplayer enabled Resume from queue
Audio (active/inactive) dismiss Idle Hide miniplayer Clear session
Audio (active/inactive) play_item(movie) Movie (active) Switch to video player Replace session
Movie (active) playback_ended Idle Hide video player Auto-dismiss
Movie (active) dismiss Idle Hide video player User dismiss
TvShow (active) playback_ended TvShow (inactive) Show next episode UI Wait for user choice
TvShow (inactive) next_episode TvShow (active) Play next episode Stay in session
TvShow (inactive) series_complete Idle Hide video player No more episodes

Key Design Decisions:

  1. Audio Sessions Persist: Miniplayer stays visible even when queue ends, allows easy resume
  2. Video Sessions Auto-Dismiss: Movies auto-close when finished (unless paused)
  3. Single Active Session: Playing new content type replaces current session
  4. Explicit Dismiss for Audio: User must click close button to clear audio session
  5. Session != PlayerState: Session is higher-level, PlayerState tracks playing/paused/seeking

Edge Cases Handled:

  • Album finishes: Session goes inactive, miniplayer shows last track with play disabled
  • User wants to dismiss: Close button clears session -> Idle
  • Switch content types: New session replaces old (audio -> movie)
  • Paused for extended time: Session persists indefinitely
  • Playback errors: Session stays inactive, allows retry
  • Queue operations while idle: Queue exists but no session created until play

Player State Machine (Low-Level Playback)

Location: src-tauri/src/player/state.rs

The player uses a deterministic state machine with 6 states (operates within a media session):

stateDiagram-v2
    [*] --> Idle
    Idle --> Loading : Load
    Loading --> Playing : MediaLoaded
    Playing --> Paused : Pause
    Paused --> Playing : Play
    Paused --> Seeking : Seek
    Seeking --> Playing : PositionUpdate
    Playing --> Idle : Stop
    Paused --> Idle : Stop
    Idle --> Error : Error
    Loading --> Error : Error
    Playing --> Error : Error
    Paused --> Error : Error
    Seeking --> Error : Error

    state Playing {
        [*] : position, duration
    }
    state Paused {
        [*] : position, duration
    }
    state Seeking {
        [*] : target
    }
    state Error {
        [*] : error message
    }

State Enum:

pub enum PlayerState {
    Idle,
    Loading { media: MediaItem },
    Playing { media: MediaItem, position: f64, duration: f64 },
    Paused { media: MediaItem, position: f64, duration: f64 },
    Seeking { media: MediaItem, target: f64 },
    Error { media: Option<MediaItem>, error: String },
}

Event Enum:

pub enum PlayerEvent {
    Load(MediaItem),
    Play,
    Pause,
    Stop,
    Seek(f64),
    Next,
    Previous,
    MediaLoaded(f64),      // duration
    PositionUpdate(f64),   // position
    PlaybackEnded,
    Error(String),
}

Playback Mode State Machine

Location: src-tauri/src/playback_mode/mod.rs

The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):

stateDiagram-v2
    [*] --> Idle

    Idle --> Local : play_queue()
    Idle --> Remote : transfer_to_remote(session_id)

    Local --> Remote : transfer_to_remote(session_id)
    Local --> Idle : stop()

    Remote --> Local : transfer_to_local()
    Remote --> Idle : session_disconnected()
    Remote --> Idle : stop()

    state Local {
        [*] : Playing on device
        [*] : ExoPlayer active
        [*] : Volume buttons -> device
    }

    state Remote {
        [*] : Controlling session
        [*] : session_id
        [*] : Volume buttons -> remote
        [*] : Android: VolumeProvider active
    }

    state Idle {
        [*] : No active playback
    }

State Enum:

pub enum PlaybackMode {
    Local,                           // Playing on local device
    Remote { session_id: String },   // Controlling remote Jellyfin session
    Idle,                            // No active playback
}

State Transitions:

From Event To Side Effects
Idle play_queue() Local Start local playback
Idle transfer_to_remote(session_id) Remote Send queue to remote session
Local transfer_to_remote(session_id) Remote Stop local, send queue to remote, enable remote volume (Android)
Local stop() Idle Stop local playback
Remote transfer_to_local() Local Get remote state, stop remote, start local at same position, disable remote volume
Remote stop() Idle Stop remote playback, disable remote volume
Remote session_disconnected() Idle Session lost, disable remote volume

Integration with Player State Machine:

  • When PlaybackMode = Local: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
  • When PlaybackMode = Remote: Player state is typically Idle (remote session controls playback)
  • When PlaybackMode = Idle: Player state is Idle

Android Volume Control Integration:

When transitioning to Remote mode on Android:

  1. Call enable_remote_volume(initial_volume)
  2. VolumeProviderCompat intercepts hardware volume buttons
  3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
  4. Volume commands routed to remote session via Jellyfin API

When transitioning away from Remote mode:

  1. Call disable_remote_volume()
  2. Volume buttons return to controlling device volume
  3. PlaybackStateCompat set to STATE_NONE
  4. VolumeProviderCompat is cleared

Media Item & Source

Location: src-tauri/src/player/media.rs

pub struct MediaItem {
    pub id: String,
    pub title: String,
    pub artist: Option<String>,
    pub album: Option<String>,
    pub duration: Option<f64>,
    pub artwork_url: Option<String>,
    pub media_type: MediaType,
    pub source: MediaSource,
}

pub enum MediaType {
    Audio,
    Video,
}

pub enum MediaSource {
    Remote {
        stream_url: String,
        jellyfin_item_id: String,
    },
    Local {
        file_path: PathBuf,
        jellyfin_item_id: Option<String>,
    },
    DirectUrl {
        url: String,
    },
}

The MediaSource enum enables:

  • Remote: Streaming from Jellyfin server
  • Local: Downloaded/cached files (future offline support)
  • DirectUrl: Direct URLs (channel plugins, external sources)

Queue Manager

Location: src-tauri/src/player/queue.rs

pub struct QueueManager {
    items: Vec<MediaItem>,
    current_index: Option<usize>,
    shuffle: bool,
    repeat: RepeatMode,
    shuffle_order: Vec<usize>,  // Fisher-Yates permutation
    history: Vec<usize>,        // For back navigation in shuffle
}

pub enum RepeatMode {
    Off,
    All,
    One,
}

Queue Navigation Logic:

flowchart TB
    QM[QueueManager]
    QM --> Shuffle
    QM --> Repeat
    QM --> History

    subgraph Shuffle["Shuffle Mode"]
        ShuffleOff["OFF<br/>next() returns index + 1"]
        ShuffleOn["ON<br/>next() follows shuffle_order[]"]
    end

    subgraph Repeat["Repeat Mode"]
        RepeatOff["OFF<br/>next() at end: -> None"]
        RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
        RepeatOne["ONE<br/>next() returns same item"]
    end

    subgraph History["History"]
        HistoryDesc["Used for previous()<br/>in shuffle mode"]
    end

Favorites System

Location:

  • Commands: src-tauri/src/commands/favorites.rs (offline drain), src-tauri/src/commands/repository.rs (query + toggle), src-tauri/src/commands/storage/ (local user_data writes)
  • Repository: get_favorites on the trait, implemented by online.rs, offline.rs and hybrid.rs
  • Frontend: src/lib/services/favorites.ts, src/lib/components/FavoriteButton.svelte, /library/favorites

Favouriting has two halves that are easy to confuse: marking an item, which has existed since UR-017, and browsing what was marked, which arrived with UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it.

Marking

Optimistic local write, then server sync:

flowchart TB
    UI[FavoriteButton] -->|Click| Service[toggleFavorite]
    Service -->|"1. Optimistic"| LocalDB[("SQLite user_data<br/>is_favorite, pending_sync")]
    Service -->|"2. Sync"| Repo[Repository]
    Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"]
    Service -->|"3. Mark synced"| LocalDB
    Drain["spawn_favorites_drain<br/>(background task)"] -->|"pending_sync = 1"| Repo
  1. The local row is updated immediately, so the heart fills without a round trip.
  2. The repository is asked to mark or unmark on the server.
  3. On success pending_sync is cleared; on failure the row stays pending.
  4. A background drain (spawn_favorites_drain, started in lib.rs setup) retries pending rows, so a favourite marked offline still reaches the server (DR-120). This is the same pattern as the sync-queue drain — see Background workers.

Browsing

get_favorites(scope, options) answers "what did this user favourite", across libraries, with the scope owned by Rust — the frontend sends a SearchScope variant and never names an item type. HybridRepository splits it the same way it splits every query:

Method Used for
get_favorites_cache_only The instant leg — the local user_data join
get_favorites_server_only The reconciliation leg
get_favorites Cache-first with server merge, per the repository's usual policy

GetItemsOptions.favorites_only is the other entry point: it filters an existing library listing rather than starting a cross-library query (DR-116), which is what a library page's favourites filter uses.

Server favourite state is mirrored into the local user_data table on catalog sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up here — before this, MediaItem.user_data was left empty and no query anywhere asked for favourites.

Tauri commands:

Command Description
repository_get_favorites Cross-library favourites for a scope
repository_mark_favorite / repository_unmark_favorite Toggle on the server, through the repository
storage_toggle_favorite Local optimistic write (is_favorite, pending_sync)
storage_mark_synced Clear pending_sync after a successful server write

Frontend surfaces (DR-117 … DR-119): the /library/favorites page with a scope selector, favourite rows on home (favoriteMovies / favoriteShows / favoriteMusic in stores/home.ts), a favourites tile per category in the library mosaic, and FavoriteButton mounted wherever a whole item is shown — movie, series, episode, album, artist and playlist detail views as well as the mini player.

Player Backend Trait

Location: src-tauri/src/player/backend.rs

pub trait PlayerBackend: Send + Sync {
    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
    fn play(&mut self) -> Result<(), PlayerError>;
    fn pause(&mut self) -> Result<(), PlayerError>;
    fn stop(&mut self) -> Result<(), PlayerError>;
    fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
    fn position(&self) -> f64;
    fn duration(&self) -> Option<f64>;
    fn state(&self) -> PlayerState;
    fn is_loaded(&self) -> bool;
    fn volume(&self) -> f32;
}

Implementations:

Player Controller

Location: src-tauri/src/player/mod.rs

The PlayerController orchestrates playback:

pub struct PlayerController {
    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
    queue: Arc<Mutex<QueueManager>>,
    muted: bool,
    sleep_timer: Arc<Mutex<SleepTimerState>>,
    autoplay_settings: Arc<Mutex<AutoplaySettings>>,
    autoplay_episode_count: Arc<Mutex<u32>>,  // Session-based counter
    repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
    // ... other fields
}

Key Methods:

  • play_item(item): Load and play single item (resets autoplay counter)
  • play_queue(items, start_index): Load queue and start playback (resets autoplay counter)
  • next() / previous(): Queue navigation (resets autoplay counter)
  • toggle_shuffle() / cycle_repeat(): Mode changes
  • set_sleep_timer(mode) / cancel_sleep_timer(): Sleep timer control
  • on_playback_ended(): Autoplay decision making (checks sleep timer, episode limit, queue)

Playlist System

Location: src-tauri/src/commands/playlist.rs, src-tauri/src/repository/

TRACES: UR-014 | JA-019 | JA-020

The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.

Types:

/// A media item within a playlist, with its distinct playlist entry ID
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistEntry {
    /// Jellyfin's PlaylistItemId (distinct from the media item ID)
    pub playlist_item_id: String,
    #[serde(flatten)]
    pub item: MediaItem,
}

/// Result of creating a new playlist
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistCreatedResult {
    pub id: String,
}

Key Design Decision: PlaylistEntry wraps a MediaItem with a distinct playlist_item_id. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.

MediaRepository Trait Methods:

async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;

Cache Strategy:

  • Write operations (create, delete, rename, add, remove, move): Delegate directly to online repository
  • Read operation (get_playlist_items): Uses cache-first parallel racing (100ms cache timeout, server fallback)
  • Background cache update after server fetch via save_playlist_items_to_cache()

Playlist Tauri Commands:

Command Parameters Returns
playlist_create handle, name, item_ids? PlaylistCreatedResult
playlist_delete handle, playlist_id ()
playlist_rename handle, playlist_id, name ()
playlist_get_items handle, playlist_id Vec<PlaylistEntry>
playlist_add_items handle, playlist_id, item_ids ()
playlist_remove_items handle, playlist_id, entry_ids ()
playlist_move_item handle, playlist_id, item_id, new_index ()

Tauri Commands (Player)

Location: src-tauri/src/commands/player.rs

Command Parameters Returns
player_play_item PlayItemRequest PlayerStatus
player_play_queue items, start_index, shuffle PlayerStatus
player_play - PlayerStatus
player_pause - PlayerStatus
player_toggle - PlayerStatus
player_stop - PlayerStatus
player_next - PlayerStatus
player_previous - PlayerStatus
player_seek position: f64 PlayerStatus
player_set_volume volume: f32 PlayerStatus
player_toggle_shuffle - QueueStatus
player_cycle_repeat - QueueStatus
player_get_status - PlayerStatus
player_get_queue - QueueStatus
player_get_session - MediaSessionType
player_dismiss_session - ()
player_set_sleep_timer mode: SleepTimerMode ()
player_cancel_sleep_timer - ()
player_set_video_settings settings: VideoSettings VideoSettings
player_get_video_settings - VideoSettings
player_set_autoplay_settings settings: AutoplaySettings AutoplaySettings
player_get_autoplay_settings - AutoplaySettings
player_on_playback_ended - ()

Domain Vocabulary Owned by Rust

The frontend is presentation-only and must not encode Jellyfin's taxonomy — the rule in CLAUDE.md and scoped-search-boundary.md. These are the places where that vocabulary actually lives.

Search scope and the taxonomy boundary

Location: src-tauri/src/repository/types.rs

SearchScope is the canonical example the boundary rule is taught from. The frontend sends an opaque variant; Rust expands it into Jellyfin item types:

pub enum SearchScope { All, Music, Movies, Tv }

impl SearchScope {
    /// The Jellyfin item types this scope requests, or `None` for `All`.
    pub fn item_types(self) -> Option<Vec<String>> {  }

    /// The scope a library of this Jellyfin `CollectionType` belongs to.
    pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {  }
}

Two details that are load-bearing:

  • All returns None, not the union of every listed type. An explicit includeItemTypes list filters out anything not named in it, so a union would silently drop People, folders, and any type nobody enumerated. Callers must omit the filter entirely on None.
  • for_collection_type maps a Jellyfin CollectionType to a favourites category (DR-175). It changes when Jellyfin renames a collection type, not when the library page is redesigned — which is the test for whether something belongs on this side of the boundary.

⚠️ The result side has not moved yet. GROUP_ITEM_TYPES in src/lib/utils/searchScope.ts still maps result groups to item types in the frontend, and check:boundary does not match its shape. Tracked as Stage 2 of scoped-search-boundary-implementation.md.

Library exclusions

Location: src-tauri/src/repository/exclusions.rs (TRACES: UR-076 | DR-209)

Folders the user has chosen to keep out of music browsing — a "Podcasts" folder inside a music library being the canonical case. Excluded by item id, not by name, in a process-wide RwLock<Vec<String>> restored from the database at startup, and applied by the repository layer to every music query (libraries, artists, albums, genres, search, home rows).

The id is normalised (trim, strip -, lowercase) because Jellyfin writes the same GUID both dashed and undashed depending on the endpoint. The predecessor was a frontend filter matching the English string "Podcasts" — wrong in three ways at once, and the reason this lives in the repository.

The set is process-wide rather than a field on a repository for the same reason as online::STREAMING_QUALITY: it is a preference about this user's browsing, not about a server session, so it must survive a repository being rebuilt on re-login.

Streaming quality ladder

Location: src-tauri/src/settings.rs (TRACES: UR-074 | DR-162)

StreamingQuality is a bandwidth ladder (Original, 20/10/8/4/2/1 Mbps, 720 kbps), not a resolution picker: it exists to fit a connection, and the resolution cap is chosen from the bitrate so the encoder does not spend a small budget on pixels it cannot afford.

Method Answers
max_bitrate() Total bits/s (video + audio), None for Original
audio_bitrate() The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom
video_bitrate() Total minus audio, so the two together honour the ceiling
max_height() Resolution ceiling that suits the bitrate

The ceiling goes to PlaybackInfo as MaxStreamingBitrate and into the device profile. Sending it there — not just on the transcode URL — is what makes the cap real: a stream the server decides to direct play is served at the source file's own bitrate, and no URL parameter afterwards can reduce it.

The frontend names a variant and nothing else; the labels the picker shows are served over IPC by player_get_streaming_qualities.

Background workers

Three long-lived tasks are spawned from the Tauri setup hook in lib.rs. All three exist because when something happens is a backend policy, not something a page load should decide.

Worker Location Responsibility
spawn_catalog_indexer commands/catalog.rs Keeps the local FTS5 catalog fresh (DR-109, IR-030)
spawn_favorites_drain commands/favorites.rs Retries favourite toggles made while offline (DR-120)
spawn_sync_queue_drain commands/sync_drain.rs Drains the offline mutation queue (DR-131)

Catalog indexer

Replaces the frontend's startup-only syncCatalog() call. It ticks on CATALOG_INDEX_TICK and runs a pass when three things hold: a repository exists, the server is reachable, and the index is due per index_is_due. A tick is nearly free — one indexed app_settings lookup — which is what makes it responsive to events it cannot subscribe to, such as signing in: a fresh install would otherwise sit unindexed until the next scheduled pass.

index_is_due treats both "never indexed" and an unparseable stored timestamp as due; a corrupt timestamp should trigger a re-index, not silently freeze the catalog. A failed pass is never fatal — it leaves the existing index in place and warns. Progress is emitted on CATALOG_INDEX_EVENT for the staleness hint in the UI.