Search's instant leg read only downloaded items, so with no downloads it returned nothing and every keystroke fell through to a full Recursive=true server query. It now reads the whole synced catalog through the same availability CTE get_items uses, gated on the same include_catalog_browse flag so search and browse cannot diverge. (UR-065, DR-108) Also fixes three defects found while confirming that: - items_fts grew by a full duplicate index every catalog pass. INSERT OR REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement took a fresh rowid and inserted a second entry. Now a real upsert, with migration 021 rebuilding existing indexes. (DR-110) - DELETE FROM items existed nowhere, so server-side deletions never propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl because items.parent_id cascades. (DR-110) - The index omitted MusicArtist, Playlist and People, which search groups results by. Adds them plus people_fts (migration 022). (DR-111) Re-indexing moves from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog and a restart no longer forces a crawl regardless of freshness. (DR-109, IR-030) Downloads gain a lifetime tier. Eviction selected every completed row by age with no download_source filter, so hitting the storage limit deleted the oldest download -- typically one saved deliberately for offline -- to make room for a precached track. It now reclaims only 'auto' rows, and expired ones are reclaimed first, before live cache is evicted. (DR-126, DR-127) Downloaded video and audio-only handoffs now play from disk instead of streaming; the video path had never consulted downloads at all. No transcode is involved: MPV runs video=no and ExoPlayer has no surface for an Audio item. (DR-123 in part, DR-128) FTS queries are built as quoted phrases so apostrophes, hyphens and slashes are data rather than operator syntax, and the item-type filter is bound rather than interpolated. Specs: docs/specs/catalog-index-search.md, docs/specs/read-through-media-cache.md Includes concurrently-developed favourites browsing and background-audio stream-end handling; the two workstreams share offline.rs, lib.rs and online.rs, so no subset of files builds independently.
114 lines
3.6 KiB
Rust
114 lines
3.6 KiB
Rust
// Autoplay decision logic
|
|
// TRACES: UR-023, UR-026 | DR-047, DR-048, DR-029
|
|
use crate::repository::types::MediaItem;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Autoplay decision result - determines what happens after playback ends
|
|
#[derive(specta::Type, Debug, Clone, Serialize)]
|
|
#[serde(tag = "action", rename_all = "camelCase")]
|
|
pub enum AutoplayDecision {
|
|
/// Stop playback (no next item or timer expired)
|
|
Stop,
|
|
/// Advance to next track in queue (for audio/movies)
|
|
AdvanceToNext,
|
|
/// The stream ended well short of the item's runtime — the connection
|
|
/// dropped, not the media. Re-open the same stream at `position` instead of
|
|
/// running any end-of-item logic (UR-040).
|
|
ResumeStream { position: f64 },
|
|
/// Show next episode popup with countdown
|
|
ShowNextEpisodePopup {
|
|
current_episode: MediaItem,
|
|
next_episode: MediaItem,
|
|
countdown_seconds: u32,
|
|
auto_advance: bool,
|
|
},
|
|
}
|
|
|
|
/// Autoplay settings (controls next episode behavior)
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AutoplaySettings {
|
|
/// Whether autoplay is enabled for next episodes
|
|
pub enabled: bool,
|
|
/// Countdown duration in seconds before auto-playing next episode
|
|
pub countdown_seconds: u32,
|
|
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
|
#[serde(default)]
|
|
pub max_episodes: u32,
|
|
}
|
|
|
|
impl Default for AutoplaySettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
countdown_seconds: 10,
|
|
max_episodes: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AutoplaySettings {
|
|
/// Validate and clamp countdown seconds to reasonable range (5-30 seconds)
|
|
pub fn with_validated_countdown(mut self) -> Self {
|
|
self.countdown_seconds = self.countdown_seconds.clamp(5, 30);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_autoplay_settings_defaults() {
|
|
let settings = AutoplaySettings::default();
|
|
assert!(settings.enabled);
|
|
assert_eq!(settings.countdown_seconds, 10);
|
|
assert_eq!(settings.max_episodes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_autoplay_settings_backward_compat() {
|
|
// Deserialize old JSON without max_episodes field
|
|
let json = r#"{"enabled":true,"countdownSeconds":15}"#;
|
|
let settings: AutoplaySettings = serde_json::from_str(json).unwrap();
|
|
assert!(settings.enabled);
|
|
assert_eq!(settings.countdown_seconds, 15);
|
|
assert_eq!(settings.max_episodes, 0); // defaults to 0 (unlimited)
|
|
}
|
|
|
|
#[test]
|
|
fn test_autoplay_settings_with_max_episodes() {
|
|
let json = r#"{"enabled":true,"countdownSeconds":10,"maxEpisodes":5}"#;
|
|
let settings: AutoplaySettings = serde_json::from_str(json).unwrap();
|
|
assert_eq!(settings.max_episodes, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_countdown_validation() {
|
|
let settings = AutoplaySettings {
|
|
enabled: true,
|
|
countdown_seconds: 2, // Too short
|
|
max_episodes: 0,
|
|
}
|
|
.with_validated_countdown();
|
|
assert_eq!(settings.countdown_seconds, 5); // Clamped to min
|
|
|
|
let settings = AutoplaySettings {
|
|
enabled: true,
|
|
countdown_seconds: 60, // Too long
|
|
max_episodes: 0,
|
|
}
|
|
.with_validated_countdown();
|
|
assert_eq!(settings.countdown_seconds, 30); // Clamped to max
|
|
|
|
let settings = AutoplaySettings {
|
|
enabled: true,
|
|
countdown_seconds: 15, // Valid
|
|
max_episodes: 0,
|
|
}
|
|
.with_validated_countdown();
|
|
assert_eq!(settings.countdown_seconds, 15); // Unchanged
|
|
}
|
|
}
|