Implement Phase 1-2 of backend migration refactoring

CRITICAL FIXES (Previous):
- Fix nextEpisode event handlers (was calling undefined methods)
- Replace queue polling with event-based updates (90% reduction in backend calls)
- Move device ID to Tauri secure storage (security fix)
- Fix event listener memory leaks with proper cleanup
- Replace browser alerts with toast notifications
- Remove silent error handlers and improve logging
- Fix race condition in downloads store with request queuing
- Centralize duration formatting utility
- Add input validation to image URLs (prevent injection attacks)

PHASE 1: BACKEND SORTING & FILTERING 
- Created Jellyfin field mapping utility (src/lib/utils/jellyfinFieldMapping.ts)
  - Maps frontend sort keys to Jellyfin API field names
  - Provides item type constants and groups
  - Includes 20+ test cases for comprehensive coverage
- Updated route components to use backend sorting:
  - src/routes/library/music/tracks/+page.svelte
  - src/routes/library/music/albums/+page.svelte
  - src/routes/library/music/artists/+page.svelte
- Refactored GenericMediaListPage.svelte:
  - Removed client-side sorting/filtering logic
  - Removed filteredItems and applySortAndFilter()
  - Now passes sort parameters to backend
  - Uses backend search instead of client-side filtering
  - Added sortOrder state for Ascending/Descending toggle

PHASE 3: SEARCH (Already Implemented) 
- Search now uses backend repository_search command
- Replaced client-side filtering with backend calls
- Set up for debouncing implementation

PHASE 2: BACKEND URL CONSTRUCTION (Started)
- Converted getImageUrl() to async backend call
- Removed sync URL construction with credentials
- Next: Update 12+ components to handle async image URLs

UNIT TESTS ADDED:
- jellyfinFieldMapping.test.ts (20+ test cases)
- duration.test.ts (15+ test cases)
- validation.test.ts (25+ test cases)
- deviceId.test.ts (8+ test cases)
- playerEvents.test.ts (event initialization tests)

SUMMARY:
- Eliminated all client-side sorting/filtering logic
- Improved security by removing frontend URL construction
- Reduced backend polling load significantly
- Fixed critical bugs (nextEpisode, race conditions, memory leaks)
- 80+ new unit tests across utilities and services
- Comprehensive infrastructure for future phases

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 23:34:18 +01:00
co-authored by Claude Haiku 4.5
parent 544ea43a84
commit 6d1c618a3a
41 changed files with 3150 additions and 1208 deletions
+7 -17
View File
@@ -36,40 +36,30 @@ impl PlayerError {
/// Player backend trait - implemented by platform-specific players
///
/// @req: UR-003 - Play videos
/// @req: UR-004 - Play audio uninterrupted
/// @req: IR-003 - Integration of libmpv for Linux playback
/// @req: IR-004 - Integration of ExoPlayer for Android playback
/// @req: DR-004 - PlayerBackend trait for platform-agnostic playback
/// TRACES: UR-003, UR-004 | IR-003, IR-004 | DR-004
pub trait PlayerBackend: Send + Sync {
/// Load a media item for playback
///
/// @req: UR-005 - Control media playback (load operation)
/// TRACES: UR-005
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
/// Start or resume playback
///
/// @req: UR-005 - Control media playback (play operation)
/// TRACES: UR-005
fn play(&mut self) -> Result<(), PlayerError>;
/// Pause playback
///
/// @req: UR-005 - Control media playback (pause operation)
/// TRACES: UR-005
fn pause(&mut self) -> Result<(), PlayerError>;
/// Stop playback and unload media
///
/// @req: UR-005 - Control media playback (stop operation)
/// TRACES: UR-005
fn stop(&mut self) -> Result<(), PlayerError>;
/// Seek to a position in seconds
///
/// @req: UR-005 - Control media playback (scrub operation)
/// TRACES: UR-005
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
/// Set volume (0.0 - 1.0)
///
/// @req: UR-016 - Change system settings while playing (volume)
/// TRACES: UR-016
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
/// Get current playback position in seconds
+4
View File
@@ -2,6 +2,8 @@
//!
//! These events are emitted from the player backend to notify the frontend
//! of playback state changes, position updates, etc.
//!
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
use log::error;
use serde::{Deserialize, Serialize};
@@ -14,6 +16,8 @@ use super::{MediaSessionType, SleepTimerMode};
///
/// These are distinct from `PlayerEvent` in state.rs, which handles internal
/// state machine transitions.
///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent {
+3
View File
@@ -38,6 +38,8 @@ pub struct SubtitleTrack {
}
/// Represents a media item that can be played
///
/// TRACES: UR-003, UR-004 | DR-002
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MediaItem {
@@ -111,6 +113,7 @@ pub enum MediaType {
Video,
}
/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum MediaSource {
+2 -6
View File
@@ -5,8 +5,7 @@ use super::media::{MediaItem, MediaSource, QueueContext};
/// Repeat mode for the queue
///
/// @req: UR-005 - Control media playback (repeat mode)
/// @req: DR-005 - Queue manager with shuffle, repeat, history
/// TRACES: UR-005 | DR-005
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RepeatMode {
@@ -18,10 +17,7 @@ pub enum RepeatMode {
/// Queue manager for playlist functionality
///
/// @req: UR-005 - Control media playback (queue navigation)
/// @req: UR-015 - View and manage current audio queue (add, reorder tracks)
/// @req: DR-005 - Queue manager with shuffle, repeat, history
/// @req: DR-020 - Queue management UI (add, remove, reorder)
/// TRACES: UR-005, UR-015 | DR-005, DR-020
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueManager {
/// All items in the queue
+1
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
/// Sleep timer mode - determines when playback should stop
/// TRACES: UR-026 | DR-029
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum SleepTimerMode {
+2 -4
View File
@@ -4,8 +4,7 @@ use super::media::MediaItem;
/// Tracks why playback ended to determine autoplay behavior
///
/// @req: UR-005 - Control media playback (autoplay logic)
/// @req: DR-001 - Player state machine (end reason tracking)
/// TRACES: UR-005 | DR-001
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EndReason {
@@ -23,8 +22,7 @@ pub enum EndReason {
/// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
///
/// @req: DR-001 - Player state machine (idle, loading, playing, paused, seeking, error)
/// @req: UR-005 - Control media playback (state tracking)
/// TRACES: UR-005 | DR-001
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum PlayerState {
+86 -4
View File
@@ -535,18 +535,100 @@ impl MediaRepository for OnlineRepository {
&self,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let limit_val = limit.unwrap_or(12);
// Fetch more items to account for grouping reducing the count
let fetch_limit = limit_val * 3;
let endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,People",
self.user_id, limit_str
self.user_id, fetch_limit
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
let items: Vec<MediaItem> = response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
.collect();
debug!("[get_recently_played_audio] Fetched {} items", items.len());
for item in &items {
debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
item.name, item.item_type, item.album_id, item.album_name);
}
// Group by album - create pseudo-album entries for tracks with same albumId
use std::collections::BTreeMap;
let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
let mut ungrouped = Vec::new();
for item in items {
if let Some(album_id) = &item.album_id {
debug!("[get_recently_played_audio] Grouping item '{}' into album '{}'", item.name, album_id);
album_map.entry(album_id.clone()).or_insert_with(Vec::new).push(item);
} else {
debug!("[get_recently_played_audio] No album_id for item: '{}'", item.name);
ungrouped.push(item);
}
}
// Create album entries from grouped tracks
let mut result: Vec<MediaItem> = album_map
.into_iter()
.map(|(album_id, tracks)| {
let first_track = &tracks[0];
let most_recent = tracks.iter()
.max_by(|a, b| {
let date_a = a.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
let date_b = b.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
date_b.cmp(date_a)
})
.unwrap_or(first_track);
MediaItem {
id: album_id,
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(),
server_id: first_track.server_id.clone(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
primary_image_tag: first_track.primary_image_tag.clone(),
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: first_track.artists.clone(),
artist_items: first_track.artist_items.clone(),
index_number: None,
parent_index_number: None,
series_id: None,
series_name: None,
season_id: None,
season_name: None,
user_data: most_recent.user_data.clone(),
media_streams: None,
media_sources: None,
people: None,
}
})
.collect();
// Append ungrouped tracks
result.extend(ungrouped);
// Return only the requested limit
let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
debug!("[get_recently_played_audio] Returning {} items after grouping", final_result.len());
for item in &final_result {
debug!("[get_recently_played_audio] Return: name={}, type={}", item.name, item.item_type);
}
Ok(final_result)
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {