First working POC
49
.gitignore
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# WebdriverIO E2E tests
|
||||||
|
e2e/logs/
|
||||||
|
e2e/screenshots/
|
||||||
|
wdio-*.log
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
.vitest
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
212
PRELOADING.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# Smart Preloading Implementation
|
||||||
|
|
||||||
|
This document describes the smart preloading system that automatically queues downloads for upcoming tracks in the playback queue.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The preloading system monitors playback and automatically queues background downloads for the next 3 tracks in the queue. This ensures smooth playback transitions and offline availability without requiring manual downloads.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
**Smart Cache Engine** - `/src-tauri/src/download/cache.rs`
|
||||||
|
- Manages preload configuration
|
||||||
|
- Default settings: preload 3 tracks, works on any connection (not wifi-only)
|
||||||
|
- Storage limit: 10GB
|
||||||
|
- Album affinity tracking (future feature)
|
||||||
|
|
||||||
|
**Queue Manager** - `/src-tauri/src/player/queue.rs`
|
||||||
|
- New method: `get_upcoming(count)` returns next N tracks
|
||||||
|
- Respects shuffle order
|
||||||
|
- Handles repeat modes (Off, All, One)
|
||||||
|
- Wraps around when RepeatMode::All is enabled
|
||||||
|
|
||||||
|
**Preload Command** - `/src-tauri/src/commands/player.rs`
|
||||||
|
- `player_preload_upcoming` - Main preload endpoint
|
||||||
|
- Checks SmartCache configuration
|
||||||
|
- Gets upcoming tracks from queue
|
||||||
|
- Skips already downloaded/queued items
|
||||||
|
- Queues new downloads with low priority (-100)
|
||||||
|
- Returns stats: queued_count, already_downloaded, skipped
|
||||||
|
|
||||||
|
**Configuration Commands**
|
||||||
|
- `player_set_cache_config` - Update preload settings
|
||||||
|
- `player_get_cache_config` - Get current settings
|
||||||
|
|
||||||
|
### Frontend (TypeScript/Svelte)
|
||||||
|
|
||||||
|
**Preload Service** - `/src/lib/services/preload.ts`
|
||||||
|
- Main function: `preloadUpcomingTracks()`
|
||||||
|
- Automatically gets current user ID from auth store
|
||||||
|
- Calls backend preload command
|
||||||
|
- Fails silently - never interrupts playback
|
||||||
|
- Logs meaningful results only
|
||||||
|
|
||||||
|
**Integration Points** - `/src/lib/services/playerEvents.ts`
|
||||||
|
1. **On playback start** - When state changes to "playing"
|
||||||
|
2. **On track advance** - After successfully advancing to next track
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Playback Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User plays track/queue
|
||||||
|
↓
|
||||||
|
Backend starts playback
|
||||||
|
↓
|
||||||
|
Emits "state_changed" event with "playing"
|
||||||
|
↓
|
||||||
|
playerEvents.handleStateChanged() catches event
|
||||||
|
↓
|
||||||
|
Calls preloadUpcomingTracks()
|
||||||
|
↓
|
||||||
|
Backend queues downloads for next 3 tracks (if not already downloaded)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Track Advance Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Track ends (or user clicks next)
|
||||||
|
↓
|
||||||
|
playerEvents.handlePlaybackEnded() or manual next()
|
||||||
|
↓
|
||||||
|
Calls player_next backend command
|
||||||
|
↓
|
||||||
|
Backend plays next track, emits "state_changed"
|
||||||
|
↓
|
||||||
|
Calls preloadUpcomingTracks() again
|
||||||
|
↓
|
||||||
|
Queue is shifted forward, new track gets preloaded
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Default Settings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
queuePrecacheEnabled: true,
|
||||||
|
queuePrecacheCount: 3,
|
||||||
|
albumAffinityEnabled: true,
|
||||||
|
albumAffinityThreshold: 3,
|
||||||
|
storageLimit: 10737418240, // 10GB
|
||||||
|
wifiOnly: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { updateCacheConfig } from '$lib/services/preload';
|
||||||
|
|
||||||
|
await updateCacheConfig({
|
||||||
|
queuePrecacheCount: 5, // Preload 5 tracks instead of 3
|
||||||
|
wifiOnly: true // Only preload on WiFi
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Intelligent Queueing
|
||||||
|
- ✅ Checks if tracks are already downloaded
|
||||||
|
- ✅ Skips tracks already in download queue
|
||||||
|
- ✅ Low priority (-100) so user-initiated downloads go first
|
||||||
|
- ✅ Respects shuffle and repeat modes
|
||||||
|
- ✅ No duplicate downloads
|
||||||
|
|
||||||
|
### Offline First
|
||||||
|
- ✅ Existing `create_media_item()` in player.rs checks local downloads first
|
||||||
|
- ✅ Preloading ensures next tracks become local over time
|
||||||
|
- ✅ Seamless offline playback without manual intervention
|
||||||
|
|
||||||
|
### Non-Intrusive
|
||||||
|
- ✅ Background operation - never blocks playback
|
||||||
|
- ✅ Fails silently - errors are logged but don't affect UX
|
||||||
|
- ✅ Automatic - no user interaction required
|
||||||
|
- ✅ Configurable - users can adjust or disable
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `src-tauri/src/player/queue.rs` - Added `get_upcoming()` method
|
||||||
|
- `src-tauri/src/download/cache.rs` - Made `CacheConfig` serializable, updated defaults
|
||||||
|
- `src-tauri/src/commands/player.rs` - Added preload commands and `SmartCacheWrapper`
|
||||||
|
- `src-tauri/src/lib.rs` - Initialized SmartCache, registered commands
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `src/lib/services/preload.ts` - New preload service (created)
|
||||||
|
- `src/lib/services/playerEvents.ts` - Integrated preload triggers
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- `src-tauri/Cargo.toml` - Added tempfile dev dependency for tests
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Rust Tests
|
||||||
|
```bash
|
||||||
|
cd src-tauri
|
||||||
|
cargo test queue::tests::test_get_upcoming
|
||||||
|
cargo test cache::tests::test_default_config
|
||||||
|
```
|
||||||
|
|
||||||
|
All tests pass ✅
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. **Start playback**
|
||||||
|
- Play a queue of 5+ tracks
|
||||||
|
- Check console for: `[Preload] Queued N track(s) for background download`
|
||||||
|
- Verify download queue shows 3 pending downloads with priority -100
|
||||||
|
|
||||||
|
2. **Track advance**
|
||||||
|
- Let track finish or click next
|
||||||
|
- Check console for new preload log
|
||||||
|
- Verify queue shifts (old track 2 becomes current, new track gets queued)
|
||||||
|
|
||||||
|
3. **Repeat mode**
|
||||||
|
- Enable Repeat All
|
||||||
|
- Play to end of queue
|
||||||
|
- Verify wraps around and continues preloading
|
||||||
|
|
||||||
|
4. **Already downloaded**
|
||||||
|
- Download all tracks in an album
|
||||||
|
- Play the album
|
||||||
|
- Check logs show: `already_downloaded: 3, queued: 0`
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Album Affinity** - If user plays 3+ tracks from an album, auto-download the rest
|
||||||
|
2. **WiFi Detection** - Respect `wifi_only` setting on mobile
|
||||||
|
3. **Storage Management** - Auto-evict LRU items when storage limit reached
|
||||||
|
4. **Smart Priority** - Boost priority as track gets closer in queue
|
||||||
|
5. **Bandwidth Throttling** - Limit download speed to not interfere with streaming
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Preload not working
|
||||||
|
- Check console for `[Preload]` logs
|
||||||
|
- Verify user is logged in: `auth.getUserId()` returns value
|
||||||
|
- Check SmartCache config: `queuePrecacheEnabled` should be true
|
||||||
|
|
||||||
|
### Downloads not starting
|
||||||
|
- Preload only queues downloads, doesn't start them
|
||||||
|
- Check download manager is processing queue
|
||||||
|
- Verify backend has download worker running
|
||||||
|
|
||||||
|
### Too many downloads
|
||||||
|
- Reduce `queuePrecacheCount` in config
|
||||||
|
- Enable `wifiOnly` mode
|
||||||
|
- Adjust `storageLimit`
|
||||||
|
|
||||||
|
## Performance Impact
|
||||||
|
|
||||||
|
- **Minimal** - Background downloads use low priority
|
||||||
|
- **Non-blocking** - Async operation, no playback delay
|
||||||
|
- **Bandwidth-friendly** - Only downloads when needed
|
||||||
|
- **Storage-aware** - Respects configured limits
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Smart preloading transforms JellyTau into an offline-first music player. By automatically queueing downloads for upcoming tracks, it ensures seamless playback and offline availability without requiring users to manually manage downloads. The system is intelligent (checks what's already downloaded), non-intrusive (fails silently), and configurable (users can adjust or disable).
|
||||||
495
README.md
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
# JellyTau
|
||||||
|
|
||||||
|
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements Specification
|
||||||
|
|
||||||
|
## 1. User Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Priority | Status |
|
||||||
|
|----|-------------|----------|--------|
|
||||||
|
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
|
||||||
|
| UR-002 | Access media when online or offline | High | Done |
|
||||||
|
| UR-003 | Play videos | High | Planned |
|
||||||
|
| UR-004 | Play audio uninterrupted | High | Planned |
|
||||||
|
| UR-005 | Control media playback (pause, play, skip, scrub) | High | In Progress |
|
||||||
|
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Planned |
|
||||||
|
| UR-007 | Navigate media in library | High | Done |
|
||||||
|
| UR-008 | Search media across libraries | High | Done |
|
||||||
|
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
||||||
|
| UR-010 | Control playback of Jellyfin remote sessions | Low | Planned |
|
||||||
|
| UR-011 | Download media on demand | Medium | Done |
|
||||||
|
| UR-012 | Login info shall be stored securely and persistently | High | Done |
|
||||||
|
| UR-013 | View and manage downloaded media | Medium | Done |
|
||||||
|
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Planned |
|
||||||
|
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
|
||||||
|
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
|
||||||
|
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
||||||
|
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
||||||
|
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
||||||
|
| UR-020 | Select subtitles for video content | High | Planned |
|
||||||
|
| UR-021 | Select audio track for video content | High | Planned |
|
||||||
|
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
||||||
|
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup | Medium | In Progress |
|
||||||
|
| UR-024 | View recently added content on server | Medium | Planned |
|
||||||
|
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
||||||
|
| UR-026 | Sleep timer for audio playback | Low | Planned |
|
||||||
|
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
||||||
|
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
||||||
|
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
||||||
|
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||||||
|
| UR-031 | Crossfade between audio tracks | Low | Planned (Linux only) |
|
||||||
|
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
||||||
|
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
||||||
|
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Planned |
|
||||||
|
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Planned |
|
||||||
|
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Planned |
|
||||||
|
| UR-037 | Visually appealing video library with poster grids and metadata | High | Planned |
|
||||||
|
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Planned |
|
||||||
|
| UR-039 | Navigate between main sections via bottom navigation bar | High | In Progress |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Software Requirements
|
||||||
|
|
||||||
|
### 2.1 Integration Requirements
|
||||||
|
|
||||||
|
External system integrations and platform-specific implementations.
|
||||||
|
|
||||||
|
| ID | Requirement | Category | Traces To | Status |
|
||||||
|
|----|-------------|----------|-----------|--------|
|
||||||
|
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
|
||||||
|
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
||||||
|
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | In Progress |
|
||||||
|
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
||||||
|
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
||||||
|
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | In Progress |
|
||||||
|
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
||||||
|
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | In Progress |
|
||||||
|
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
|
||||||
|
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
|
||||||
|
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
|
||||||
|
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Planned |
|
||||||
|
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
|
||||||
|
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
||||||
|
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
||||||
|
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
||||||
|
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Planned |
|
||||||
|
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||||
|
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||||
|
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||||||
|
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
||||||
|
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Planned |
|
||||||
|
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Planned |
|
||||||
|
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Planned |
|
||||||
|
|
||||||
|
### 2.2 Jellyfin API Requirements
|
||||||
|
|
||||||
|
API endpoints and data contracts required for Jellyfin integration.
|
||||||
|
|
||||||
|
| ID | Requirement | Endpoint Category | Traces To | Status |
|
||||||
|
|----|-------------|-------------------|-----------|--------|
|
||||||
|
| JA-001 | Server connection and discovery | System | UR-009 | Done |
|
||||||
|
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
|
||||||
|
| JA-003 | Get user library views | UserViews | UR-007 | Done |
|
||||||
|
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
|
||||||
|
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
||||||
|
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
||||||
|
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
||||||
|
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Planned |
|
||||||
|
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Planned |
|
||||||
|
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
||||||
|
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
||||||
|
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
||||||
|
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
|
||||||
|
| JA-014 | Get "Next Up" items | Shows | UR-023 | Planned |
|
||||||
|
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Planned |
|
||||||
|
| JA-016 | Get recently added items | Items | UR-024 | Planned |
|
||||||
|
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
|
||||||
|
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
||||||
|
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Planned |
|
||||||
|
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned |
|
||||||
|
| JA-021 | Get active sessions list | Sessions | UR-010 | Planned |
|
||||||
|
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Planned |
|
||||||
|
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Planned |
|
||||||
|
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Planned |
|
||||||
|
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Planned |
|
||||||
|
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Planned |
|
||||||
|
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
||||||
|
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
||||||
|
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Planned |
|
||||||
|
| JA-030 | Get person details and filmography | Persons | UR-036 | Planned |
|
||||||
|
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Planned |
|
||||||
|
|
||||||
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
|
Internal architecture, components, and application logic.
|
||||||
|
|
||||||
|
| ID | Requirement | Category | Traces To | Status |
|
||||||
|
|----|-------------|----------|-----------|--------|
|
||||||
|
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
|
||||||
|
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
|
||||||
|
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
|
||||||
|
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
|
||||||
|
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
|
||||||
|
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
|
||||||
|
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
|
||||||
|
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
|
||||||
|
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
|
||||||
|
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Planned |
|
||||||
|
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
|
||||||
|
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
|
||||||
|
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
|
||||||
|
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Planned |
|
||||||
|
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
|
||||||
|
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
|
||||||
|
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
|
||||||
|
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
|
||||||
|
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Planned |
|
||||||
|
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
||||||
|
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
||||||
|
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
||||||
|
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Planned |
|
||||||
|
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Planned |
|
||||||
|
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
||||||
|
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Planned |
|
||||||
|
| DR-027 | "Recently Added" home section | UI | UR-024 | Planned |
|
||||||
|
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
|
||||||
|
| DR-029 | Sleep timer with countdown and auto-stop | Player | UR-026 | Planned |
|
||||||
|
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
||||||
|
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||||||
|
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
||||||
|
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||||||
|
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Planned |
|
||||||
|
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done |
|
||||||
|
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done |
|
||||||
|
| DR-037 | Remote session browser and control UI | UI | UR-010 | Planned |
|
||||||
|
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Planned |
|
||||||
|
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Planned |
|
||||||
|
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Planned |
|
||||||
|
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Planned |
|
||||||
|
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Planned |
|
||||||
|
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Planned |
|
||||||
|
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Planned |
|
||||||
|
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | In Progress |
|
||||||
|
| DR-046 | Dedicated search page with input and results | UI | UR-039 | In Progress |
|
||||||
|
| DR-047 | Next episode auto-play popup with configurable countdown | Player | UR-023 | In Progress |
|
||||||
|
| DR-048 | Video settings (auto-play toggle, countdown duration) | Settings | UR-023, UR-026 | In Progress |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Traceability Matrix
|
||||||
|
|
||||||
|
### User Requirements to Software Requirements
|
||||||
|
|
||||||
|
| User Req | Integration Requirements | Development Requirements |
|
||||||
|
|----------|-------------------------|-------------------------|
|
||||||
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
|
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||||
|
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
||||||
|
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
||||||
|
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||||
|
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||||
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
|
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||||
|
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||||
|
| UR-010 | IR-012, IR-021 | DR-037 |
|
||||||
|
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||||
|
| UR-012 | IR-009, IR-014 | - |
|
||||||
|
| UR-013 | IR-013 | DR-017 |
|
||||||
|
| UR-014 | - | DR-014, DR-019 |
|
||||||
|
| UR-015 | - | DR-005, DR-020 |
|
||||||
|
| UR-016 | - | - |
|
||||||
|
| UR-017 | - | DR-014, DR-021 |
|
||||||
|
| UR-018 | IR-013 | DR-015, DR-018 |
|
||||||
|
| UR-019 | IR-015 | DR-022 |
|
||||||
|
| UR-020 | IR-016, IR-018 | DR-023 |
|
||||||
|
| UR-021 | IR-016, IR-019 | DR-024 |
|
||||||
|
| UR-022 | IR-017 | DR-025 |
|
||||||
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048 |
|
||||||
|
| UR-024 | IR-010 | DR-027 |
|
||||||
|
| UR-025 | IR-015 | DR-028 |
|
||||||
|
| UR-026 | - | DR-029, DR-048 |
|
||||||
|
| UR-027 | IR-020 | DR-030 |
|
||||||
|
| UR-028 | - | DR-031 |
|
||||||
|
| UR-029 | - | DR-032 |
|
||||||
|
| UR-030 | IR-010 | DR-033 |
|
||||||
|
| UR-031 | - | DR-034 |
|
||||||
|
| UR-032 | - | DR-035 |
|
||||||
|
| UR-033 | - | DR-036 |
|
||||||
|
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
|
||||||
|
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
|
||||||
|
| UR-036 | IR-022, IR-023 | DR-041 |
|
||||||
|
| UR-037 | IR-010 | DR-042 |
|
||||||
|
| UR-038 | IR-010 | DR-043 |
|
||||||
|
| UR-039 | - | DR-045, DR-046 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Test Traceability
|
||||||
|
|
||||||
|
### Unit Tests to Software Requirements
|
||||||
|
|
||||||
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|---------|-----------------|-----------|--------|
|
||||||
|
| UT-001 | Player state transitions | DR-001 | Pending |
|
||||||
|
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
|
||||||
|
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
|
||||||
|
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
|
||||||
|
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
|
||||||
|
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
|
||||||
|
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
|
||||||
|
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
|
||||||
|
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
|
||||||
|
| UT-010 | Download queue management | DR-015 | Done |
|
||||||
|
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
|
||||||
|
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
|
||||||
|
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
|
||||||
|
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
|
||||||
|
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
|
||||||
|
| UT-016 | All database tables created | IR-013, DR-012 | Done |
|
||||||
|
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
|
||||||
|
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
|
||||||
|
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
|
||||||
|
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
|
||||||
|
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
|
||||||
|
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
|
||||||
|
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
|
||||||
|
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
|
||||||
|
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
|
||||||
|
| UT-026 | NullBackend volume default value | DR-004 | Done |
|
||||||
|
| UT-027 | NullBackend set volume | DR-004 | Done |
|
||||||
|
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
|
||||||
|
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
|
||||||
|
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
|
||||||
|
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
|
||||||
|
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
|
||||||
|
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
|
||||||
|
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
|
||||||
|
| UT-035 | Download event completed serialization | DR-015 | Done |
|
||||||
|
| UT-036 | Download event failed serialization | DR-015 | Done |
|
||||||
|
| UT-037 | Download worker exponential backoff | DR-015 | Done |
|
||||||
|
| UT-038 | Download worker error retryable check | DR-015 | Done |
|
||||||
|
| UT-039 | Download manager creation | DR-015 | Done |
|
||||||
|
| UT-040 | Download manager set max concurrent | DR-015 | Done |
|
||||||
|
| UT-041 | Download info serialization | DR-015 | Done |
|
||||||
|
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
|
||||||
|
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
|
||||||
|
| UT-044 | Offline item serialization | DR-017 | Done |
|
||||||
|
| UT-045 | Smart cache default config | DR-015 | Done |
|
||||||
|
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
|
||||||
|
| UT-047 | Smart cache queue precache config | DR-015 | Done |
|
||||||
|
| UT-048 | Smart cache storage limit check | DR-015 | Done |
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|---------|-----------------|-----------|--------|
|
||||||
|
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
|
||||||
|
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
|
||||||
|
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
|
||||||
|
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
|
||||||
|
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
||||||
|
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
||||||
|
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
||||||
|
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
||||||
|
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
||||||
|
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
||||||
|
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||||
|
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Activate Rust environment (fish shell)
|
||||||
|
source "$HOME/.cargo/env.fish"
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Development
|
||||||
|
bun run tauri dev
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
bun run check
|
||||||
|
|
||||||
|
# Build for Linux
|
||||||
|
bun run tauri build
|
||||||
|
|
||||||
|
# Build for Android
|
||||||
|
bun run tauri android build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
jellytau/
|
||||||
|
├── src/ # Svelte frontend
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api/ # Jellyfin API client (repository pattern)
|
||||||
|
│ │ ├── components/ # UI components (player, library)
|
||||||
|
│ │ └── stores/ # Svelte stores (auth, library, player, queue)
|
||||||
|
│ └── routes/ # SvelteKit pages
|
||||||
|
├── src-tauri/ # Rust backend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── commands/ # Tauri commands
|
||||||
|
│ │ └── player/ # Player architecture
|
||||||
|
│ │ ├── state.rs # State machine
|
||||||
|
│ │ ├── media.rs # MediaItem, MediaSource
|
||||||
|
│ │ ├── queue.rs # Queue management
|
||||||
|
│ │ └── backend.rs # PlayerBackend trait
|
||||||
|
│ └── gen/android/ # Android project
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Technical Debt
|
||||||
|
|
||||||
|
### Linux Keyring Integration Workaround
|
||||||
|
|
||||||
|
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
|
||||||
|
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
|
||||||
|
- Session restoration fails on app restart even though credentials exist
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
|
||||||
|
|
||||||
|
**Current Workaround**:
|
||||||
|
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
|
||||||
|
- **Save**: `secret-tool store --label <label> service <service> username <username>`
|
||||||
|
- **Retrieve**: `secret-tool lookup service <service> username <username>`
|
||||||
|
- **Delete**: `secret-tool clear service <service> username <username>`
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
See [src-tauri/src/credentials.rs](src-tauri/src/credentials.rs):
|
||||||
|
- Lines 165-209: `save_to_keyring()` - Uses `secret-tool store` on Linux
|
||||||
|
- Lines 214-248: `get_from_keyring()` - Uses `secret-tool lookup` on Linux
|
||||||
|
- Lines 254-286: `delete_from_keyring()` - Uses `secret-tool clear` on Linux
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
- Monitor `keyring-rs` for bug fixes in future versions
|
||||||
|
- Consider alternative secure storage libraries
|
||||||
|
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
|
||||||
|
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Low - The workaround is functionally equivalent to proper keyring integration
|
||||||
|
- Credentials are stored securely in the system keyring
|
||||||
|
- Session restoration works correctly
|
||||||
|
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
|
||||||
|
- Already available on most Linux distributions by default
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Platform Playback Backend Parity (Linux vs Android)
|
||||||
|
|
||||||
|
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
|
||||||
|
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
|
||||||
|
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
|
||||||
|
|
||||||
|
**Affected Files**:
|
||||||
|
- [src-tauri/src/player/backend.rs:95-103](src-tauri/src/player/backend.rs) - Trait with default empty implementations
|
||||||
|
- [src-tauri/src/player/mpv/mod.rs](src-tauri/src/player/mpv/mod.rs) - Full audio settings support
|
||||||
|
- [src-tauri/src/player/android/mod.rs](src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
|
||||||
|
|
||||||
|
**Feature Parity Matrix**:
|
||||||
|
|
||||||
|
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|
||||||
|
|---------|-------------|---------------------|--------|
|
||||||
|
| Basic playback | ✅ | ✅ | Parity |
|
||||||
|
| Volume control | ✅ | ✅ | Parity |
|
||||||
|
| Seek | ✅ | ✅ | Parity |
|
||||||
|
| Crossfade | ✅ | ❌ | Gap |
|
||||||
|
| Gapless playback | ✅ | ❌ | Gap |
|
||||||
|
| Volume normalization | ✅ | ❌ | Gap |
|
||||||
|
| Position updates | 250ms | On-demand | Inconsistent |
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
||||||
|
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
||||||
|
3. Implement gapless via ExoPlayer's built-in gapless support
|
||||||
|
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
||||||
|
5. Standardize position update frequency across platforms
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Medium - Android users lack audio enhancement features advertised in requirements
|
||||||
|
- User experience differs between platforms
|
||||||
|
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
||||||
|
|
||||||
|
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Playback Code Duplication
|
||||||
|
|
||||||
|
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
|
||||||
|
- Same `$derived` state merging logic for local/remote playback in both components
|
||||||
|
- Position conversion (ticks ↔ seconds) scattered across multiple files
|
||||||
|
|
||||||
|
**Affected Files**:
|
||||||
|
- [src/lib/components/player/AudioPlayer.svelte:69-140](src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
||||||
|
- [src/lib/components/player/MiniPlayer.svelte:74-122](src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
||||||
|
- [src/lib/services/playbackControl.ts:88](src/lib/services/playbackControl.ts) - Position conversion
|
||||||
|
- [src/lib/stores/playbackMode.ts](src/lib/stores/playbackMode.ts) - Position conversion
|
||||||
|
- [src/lib/services/playbackReporting.ts](src/lib/services/playbackReporting.ts) - Position conversion
|
||||||
|
|
||||||
|
**Duplicated Code**:
|
||||||
|
```typescript
|
||||||
|
// These handlers are identical in both AudioPlayer and MiniPlayer:
|
||||||
|
handlePlayPause(), handleNext(), handlePrevious(),
|
||||||
|
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
|
||||||
|
|
||||||
|
// These derived states use identical logic:
|
||||||
|
displayMedia, displayIsPlaying, displayPosition, displayDuration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
1. Create `src/lib/utils/playbackUnits.ts`:
|
||||||
|
```typescript
|
||||||
|
export const TICKS_PER_SECOND = 10_000_000;
|
||||||
|
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
|
||||||
|
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
|
||||||
|
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
|
||||||
|
- Single source of truth for merged local/remote state
|
||||||
|
|
||||||
|
3. Simplify handler wrappers using a utility:
|
||||||
|
```typescript
|
||||||
|
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
|
||||||
|
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Low - Code works correctly but violates DRY principle
|
||||||
|
- Maintenance burden when logic needs to change
|
||||||
|
- Risk of handlers diverging over time
|
||||||
|
|
||||||
|
**Traces To**: DR-009
|
||||||
3357
SoftwareArchitecture.md
Normal file
1409
ThumbnailCachingArchitecture.md
Normal file
219
UX-ImplementationGaps.md
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
# UX Implementation Gaps
|
||||||
|
|
||||||
|
This document tracks inconsistencies between the documented UX flows ([UXFlows.md](UXFlows.md)) and the actual implementation.
|
||||||
|
|
||||||
|
**Last Updated:** 2026-01-03
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recently Resolved ✅
|
||||||
|
|
||||||
|
### 1. Route Structure Mismatch ✅ RESOLVED
|
||||||
|
|
||||||
|
**Was Documented:**
|
||||||
|
- Login screen at `/`
|
||||||
|
- Home/Library landing at `/library` (after login)
|
||||||
|
|
||||||
|
**Actual Implementation:**
|
||||||
|
- Login screen at [/login](src/routes/login/+page.svelte)
|
||||||
|
- Home page with carousels at [/](src/routes/+page.svelte)
|
||||||
|
- Library selector at [/library](src/routes/library/+page.svelte)
|
||||||
|
|
||||||
|
**Resolution:** Updated UXFlows.md Sections 1.1, 2.1, 2.2, 8.2 to reflect actual routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Audio Player Back Button Navigation ✅ RESOLVED
|
||||||
|
|
||||||
|
**Issue:**
|
||||||
|
- Audio player close button simply closed the modal without respecting browser history
|
||||||
|
- Users expected back button to return to previous page (e.g., album view)
|
||||||
|
|
||||||
|
**Resolution:**
|
||||||
|
- Updated AudioPlayer onClose handler to call `window.history.back()` ([library/+layout.svelte:212-215](src/routes/library/+layout.svelte))
|
||||||
|
- Back button now returns user to the page they were on before opening full player
|
||||||
|
- Updated UXFlows.md Section 3.3 to document browser history navigation
|
||||||
|
|
||||||
|
**Impact:** Better UX - natural back button behavior matches user expectations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Video Player Touch Gestures ✅ RESOLVED
|
||||||
|
|
||||||
|
**Was Documented:**
|
||||||
|
- Double tap left: Rewind 10 seconds
|
||||||
|
- Double tap right: Forward 10 seconds
|
||||||
|
- Swipe up/down: Adjust brightness (left) or volume (right)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- ✅ Double-tap detection on left/right sides of screen ([VideoPlayer.svelte:228-319](src/lib/components/player/VideoPlayer.svelte))
|
||||||
|
- ✅ Animated visual feedback showing "-10" or "+10" with fade-out animation
|
||||||
|
- ✅ Swipe gesture detection for vertical swipes (>50px minimum)
|
||||||
|
- ✅ Left side swipe: Brightness control (0.3-1.7x) with CSS filter
|
||||||
|
- ✅ Right side swipe: Volume control (0-100%) with real-time adjustment
|
||||||
|
- ✅ Visual indicators showing current brightness/volume level during swipe
|
||||||
|
- ✅ Updated UXFlows.md Section 4.2 to document all gestures
|
||||||
|
|
||||||
|
**Impact:** Excellent mobile video UX - touch-optimized controls match YouTube/Netflix patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Issues
|
||||||
|
|
||||||
|
### Critical Issues
|
||||||
|
|
||||||
|
None! All critical UX issues have been resolved. 🎉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Low Priority Issues
|
||||||
|
|
||||||
|
#### 1. MiniPlayer Shows More Controls Than Documented
|
||||||
|
|
||||||
|
**Documented:**
|
||||||
|
- Shows: artwork, title, artist, play/pause, next, favorite
|
||||||
|
|
||||||
|
**Actual Implementation:**
|
||||||
|
- Shows: artwork, title, artist, play/pause, next, previous, shuffle, repeat, volume (desktop), cast button, favorite (desktop)
|
||||||
|
- Much richer control set than documented
|
||||||
|
|
||||||
|
**Impact:** Low - This is actually better than documented
|
||||||
|
|
||||||
|
**Fix Required:** Update UXFlows.md Section 3.1 to document full control set
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. Search Results Display Simplified
|
||||||
|
|
||||||
|
**Documented (Section 6.1):**
|
||||||
|
- Results grouped by type: Songs, Albums, Artists, Movies, Episodes
|
||||||
|
- "See all (23)" expandable sections
|
||||||
|
|
||||||
|
**Actual Implementation:**
|
||||||
|
- [Search page](src/routes/search/+page.svelte) uses `LibraryGrid` component
|
||||||
|
- No visual grouping by type shown in code
|
||||||
|
- Simplified single-list results
|
||||||
|
|
||||||
|
**Impact:** Low - Functional but less organized than documented
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
- Option A: Implement grouped results display
|
||||||
|
- Option B: Update documentation to match simplified implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Undocumented Features
|
||||||
|
|
||||||
|
### 3. Sessions/Remote Control Feature
|
||||||
|
|
||||||
|
**Not in UXFlows.md:**
|
||||||
|
- Route exists: [/sessions](src/routes/sessions/+page.svelte)
|
||||||
|
- Appears to be for remote session control
|
||||||
|
- Cast button visible in MiniPlayer
|
||||||
|
|
||||||
|
**Impact:** None - Feature exists but isn't documented
|
||||||
|
|
||||||
|
**Fix Required:** Document sessions feature in UXFlows.md (Section 8.2 exists in SoftwareArchitecture.md but not in UX flows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. URL Query Parameters for Queue Context
|
||||||
|
|
||||||
|
**Not in UXFlows.md:**
|
||||||
|
- Uses `?queue=parent:{id}&shuffle=true` in URLs
|
||||||
|
- Enables queue restoration and context tracking
|
||||||
|
|
||||||
|
**Impact:** None - Implementation detail
|
||||||
|
|
||||||
|
**Fix Required:** Optional - add technical note in UXFlows.md about queue URL params
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consistent Implementations ✅
|
||||||
|
|
||||||
|
These areas match the documentation exactly:
|
||||||
|
|
||||||
|
- ✅ **Resume Dialog** - Correctly implements 30s/90% threshold ([player/[id]/+page.svelte:84-86](src/routes/player/[id]/+page.svelte))
|
||||||
|
- ✅ **Audio Playback** - Uses `player_play_queue` command as documented
|
||||||
|
- ✅ **MiniPlayer Hiding** - Correctly hides for video content
|
||||||
|
- ✅ **Keyboard Shortcuts** - Video player keyboard controls work as documented
|
||||||
|
- ✅ **Music Library Structure** - Category pages match documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations by Priority
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
1. ~~**Update UXFlows.md Route Structure**~~ ✅ **COMPLETED**
|
||||||
|
- ✅ Corrected login route to `/login`
|
||||||
|
- ✅ Clarified `/` is home page, `/library` is library selector
|
||||||
|
- ✅ Updated Sections 1.1, 2.1, 2.2, 7.2, 8.1, 8.2
|
||||||
|
|
||||||
|
2. ~~**Add Downloads Navigation**~~ ✅ **COMPLETED**
|
||||||
|
- ✅ Added Downloads link to header navigation (desktop)
|
||||||
|
- ✅ Added Downloads icon button to header (all screen sizes)
|
||||||
|
- ✅ Updated UXFlows.md to document navigation paths
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
3. ~~**Add Settings Navigation Link**~~ ✅ **COMPLETED**
|
||||||
|
- ✅ Added Settings link to desktop header navigation
|
||||||
|
- ✅ Added Settings to mobile overflow menu
|
||||||
|
- ✅ Updated UXFlows.md Section 8.1
|
||||||
|
|
||||||
|
4. ~~**Implement Video Player Touch Gestures**~~ ✅ **COMPLETED**
|
||||||
|
- ✅ Implemented double-tap detection (left/right sides)
|
||||||
|
- ✅ Implemented swipe gestures (brightness + volume control)
|
||||||
|
- ✅ Added visual feedback animations
|
||||||
|
- ✅ Updated UXFlows.md Section 4.2
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
5. **Document MiniPlayer Full Feature Set** (#4)
|
||||||
|
- Update Section 3.1 to show all controls
|
||||||
|
- Document desktop vs mobile differences
|
||||||
|
|
||||||
|
6. **Document Sessions Feature** (#7)
|
||||||
|
- Add UX flow for remote session selection
|
||||||
|
- Explain cast button behavior
|
||||||
|
- Link to architecture documentation
|
||||||
|
|
||||||
|
7. **Enhance Search Results Display** (#5)
|
||||||
|
- Consider implementing grouped results as documented
|
||||||
|
- Or update docs to match current implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Fix Checklist
|
||||||
|
|
||||||
|
For immediate documentation updates:
|
||||||
|
|
||||||
|
- [x] Fix login route in UXFlows.md (Section 2.1): `/` → `/login` ✅
|
||||||
|
- [x] Fix home route in UXFlows.md (Section 2.1): `/library` → `/` ✅
|
||||||
|
- [x] Document actual navigation structure (Section 1.1) ✅
|
||||||
|
- [x] Update Downloads navigation (Section 7.2) ✅
|
||||||
|
- [x] Update Settings navigation (Section 8.1) ✅
|
||||||
|
- [x] Update logout flow (Section 8.2) ✅
|
||||||
|
- [ ] Update video controls documentation (Section 4.2) to match keyboard implementation
|
||||||
|
- [ ] Document MiniPlayer cast button (Section 3.1)
|
||||||
|
- [ ] Add Sessions feature UX flow (new section)
|
||||||
|
|
||||||
|
For code implementation:
|
||||||
|
|
||||||
|
- [x] Add Downloads link to header navigation (desktop) ✅
|
||||||
|
- [x] Add Downloads icon button to header (all screen sizes) ✅
|
||||||
|
- [x] Add Settings link to header navigation ✅
|
||||||
|
- [x] Implement mobile overflow menu (Android-style) ✅
|
||||||
|
- [x] Fix audio player back button to use browser history ✅
|
||||||
|
- [ ] (Optional) Implement video player touch gestures
|
||||||
|
- [ ] (Optional) Implement grouped search results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The actual implementation is generally **more feature-rich** than documented (MiniPlayer controls, keyboard shortcuts)
|
||||||
|
- Main gaps are in **mobile navigation accessibility** (missing More tab)
|
||||||
|
- Most core functionality **matches or exceeds** documentation
|
||||||
|
- Video player touch gestures are the only **missing feature** that was explicitly documented
|
||||||
907
UXFlows.md
Normal file
@ -0,0 +1,907 @@
|
|||||||
|
# JellyTau UX Flows & Screen Transitions
|
||||||
|
|
||||||
|
This document describes the expected user experience flows, screen transitions, and navigation patterns in JellyTau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Navigation Structure
|
||||||
|
|
||||||
|
### 1.1 Navigation System
|
||||||
|
|
||||||
|
JellyTau uses a unified navigation system with a bottom navigation bar visible on all platforms (mobile and desktop) and additional header navigation for desktop.
|
||||||
|
|
||||||
|
**Bottom Navigation Bar (All Platforms - DR-045, UR-039):**
|
||||||
|
|
||||||
|
The bottom navigation bar is the primary navigation and is **always visible** on all platforms (mobile and desktop) except when:
|
||||||
|
- Full-screen video player is active
|
||||||
|
- User is on the login screen
|
||||||
|
|
||||||
|
**Bottom Nav Structure:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [Home] [Library] [Search] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
- **Home** → `/` (home page with carousels and featured content)
|
||||||
|
- **Library** → `/library` (library selector showing all libraries)
|
||||||
|
- **Search** → `/search` (dedicated search page)
|
||||||
|
|
||||||
|
**Note:** Available on both mobile and desktop for consistent navigation access.
|
||||||
|
|
||||||
|
**Header Navigation (Desktop):**
|
||||||
|
|
||||||
|
On desktop (md breakpoint and above), the header contains:
|
||||||
|
- Logo (links to `/library`)
|
||||||
|
- Navigation links: Home, Library, Downloads, Settings
|
||||||
|
- Search bar (inline)
|
||||||
|
- User menu: Username, Downloads icon, Logout button
|
||||||
|
|
||||||
|
**Mobile Navigation:**
|
||||||
|
|
||||||
|
On mobile, the header contains:
|
||||||
|
- Logo
|
||||||
|
- Three-dot overflow menu button (Android-style)
|
||||||
|
- Overflow menu includes:
|
||||||
|
- Downloads
|
||||||
|
- Settings
|
||||||
|
- Sign out
|
||||||
|
|
||||||
|
**Access Points Summary:**
|
||||||
|
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
||||||
|
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Initial App Launch Flow
|
||||||
|
|
||||||
|
### 2.1 First-Time Launch
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Launch[App Launch] --> CheckAuth{Stored<br/>Credentials?}
|
||||||
|
CheckAuth -->|No| LoginScreen[Login Screen<br/>/login]
|
||||||
|
CheckAuth -->|Yes| AutoLogin[Auto-login]
|
||||||
|
|
||||||
|
LoginScreen --> EnterURL[Enter Server URL]
|
||||||
|
EnterURL --> EnterCreds[Enter Username/Password]
|
||||||
|
EnterCreds --> LoginSuccess{Success?}
|
||||||
|
LoginSuccess -->|No| LoginError[Show Error]
|
||||||
|
LoginError --> EnterCreds
|
||||||
|
LoginSuccess -->|Yes| StoreToken[Store Token in Keyring]
|
||||||
|
|
||||||
|
AutoLogin --> TokenValid{Token Valid?}
|
||||||
|
TokenValid -->|No| LoginScreen
|
||||||
|
TokenValid -->|Yes| HomePage
|
||||||
|
|
||||||
|
StoreToken --> HomePage[Home Page<br/>/]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Screens:**
|
||||||
|
1. **Login Screen** (`/login`)
|
||||||
|
- Server URL input
|
||||||
|
- Username input
|
||||||
|
- Password input
|
||||||
|
- "Remember me" checkbox (default: on)
|
||||||
|
- Login button
|
||||||
|
- No header, no bottom nav
|
||||||
|
|
||||||
|
2. **Home Page** (`/`)
|
||||||
|
- Default landing page after successful login
|
||||||
|
- Shows featured content, carousels, continue watching
|
||||||
|
- No MiniPlayer visible (nothing playing yet)
|
||||||
|
- Bottom nav: Home tab active
|
||||||
|
- Header with navigation links
|
||||||
|
|
||||||
|
### 2.2 Subsequent Launches
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Launch[App Launch] --> LoadAuth[Load Stored Token]
|
||||||
|
LoadAuth --> Validate{Token Valid?}
|
||||||
|
Validate -->|Yes| RestoreState[Restore Last Screen]
|
||||||
|
Validate -->|No| LoginScreen[Login Screen<br/>/login]
|
||||||
|
|
||||||
|
RestoreState --> CheckPlayer{Was Player<br/>Active?}
|
||||||
|
CheckPlayer -->|Yes| ShowMiniPlayer[Show MiniPlayer<br/>at bottom]
|
||||||
|
CheckPlayer -->|No| HideMiniPlayer[No MiniPlayer]
|
||||||
|
|
||||||
|
ShowMiniPlayer --> LastScreen[Last Active Screen<br/>with MiniPlayer]
|
||||||
|
HideMiniPlayer --> HomePage[Home Page<br/>/]
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Restoration:**
|
||||||
|
- Last viewed screen (route) is restored (defaults to `/` if none)
|
||||||
|
- If audio was playing, MiniPlayer appears at bottom
|
||||||
|
- Playback state is NOT automatically resumed (user must press play)
|
||||||
|
- Queue is restored if it existed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Audio Playback Flows
|
||||||
|
|
||||||
|
### 3.1 Starting Audio Playback
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Start[User Action] --> Action{Action Type?}
|
||||||
|
|
||||||
|
Action -->|Click Track| TrackList[TrackList Component]
|
||||||
|
Action -->|Click Album| AlbumDetail[Album Detail Page]
|
||||||
|
Action -->|Click Play on Album| AlbumPlay[Play Album Button]
|
||||||
|
|
||||||
|
TrackList --> PlayTrack[Play Single Track]
|
||||||
|
PlayTrack --> QueueAll[Queue All Filtered Tracks]
|
||||||
|
|
||||||
|
AlbumPlay --> PlayAlbum[Play All Album Tracks]
|
||||||
|
PlayAlbum --> QueueAlbum[Queue Album Tracks]
|
||||||
|
|
||||||
|
QueueAll --> InvokePlay[invoke player_play_queue]
|
||||||
|
QueueAlbum --> InvokePlay
|
||||||
|
|
||||||
|
InvokePlay --> PlayerStarts[Player State: Playing]
|
||||||
|
PlayerStarts --> MiniAppears[MiniPlayer Slides Up<br/>from Bottom]
|
||||||
|
|
||||||
|
MiniAppears --> StayOnPage[User Stays on<br/>Current Screen]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Entry Points for Audio Playback:**
|
||||||
|
1. **TrackList** (`/library/music/tracks`, `/library/music/albums/[id]`)
|
||||||
|
- Click track number → Play track + queue all visible tracks
|
||||||
|
- Clicking track #3 in an album → Play track 3, queue tracks 1-10
|
||||||
|
|
||||||
|
2. **Album Card** (grid views)
|
||||||
|
- Click album → Navigate to album detail
|
||||||
|
- Play button on card → Play album immediately
|
||||||
|
|
||||||
|
3. **Search Results**
|
||||||
|
- Click track → Play track + queue search results
|
||||||
|
- Click album → Navigate to album detail
|
||||||
|
|
||||||
|
**MiniPlayer Behavior:**
|
||||||
|
- Slides up from bottom with animation (300ms)
|
||||||
|
- Height: 64px on mobile, 80px on desktop
|
||||||
|
- Shows: artwork, title, artist, play/pause, next, favorite
|
||||||
|
- Stays visible on ALL screens (except video player)
|
||||||
|
- Click anywhere on MiniPlayer → Navigate to full player
|
||||||
|
|
||||||
|
**Track Highlighting:**
|
||||||
|
When audio is playing, the currently playing track is visually highlighted in track lists and album pages:
|
||||||
|
- Subtle blue background tint
|
||||||
|
- Left border accent in Jellyfin blue
|
||||||
|
- Title text colored in Jellyfin blue
|
||||||
|
- Desktop: Animated pulsing dots indicator next to title
|
||||||
|
- Mobile: Play arrow (▶) inline with title
|
||||||
|
- Highlight updates automatically when skipping to next/previous track
|
||||||
|
|
||||||
|
### 3.2 MiniPlayer → Full Player Transition
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Mini[MiniPlayer Visible] --> UserClick{User Action}
|
||||||
|
|
||||||
|
UserClick -->|Click MiniPlayer| NavFullPlayer[Navigate to<br/>/player/[id]]
|
||||||
|
UserClick -->|Swipe Up| SwipeGesture[Swipe Gesture<br/>Planned]
|
||||||
|
|
||||||
|
NavFullPlayer --> FullPlayer[Full Audio Player Screen]
|
||||||
|
SwipeGesture --> FullPlayer
|
||||||
|
|
||||||
|
FullPlayer --> ShowControls[Show Full Controls:<br/>- Large artwork<br/>- Progress bar<br/>- Volume slider<br/>- Queue button<br/>- Shuffle/Repeat<br/>- Favorite button]
|
||||||
|
|
||||||
|
ShowControls --> MiniHidden[MiniPlayer Hidden]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full Player Screen** (`/player/[id]`)
|
||||||
|
- **Header:** Song title, artist (clickable links to artist/album pages)
|
||||||
|
- **Artwork:** Large album art (centered, dominant)
|
||||||
|
- **Progress:** Seek bar with current time / total duration
|
||||||
|
- **Controls:** Previous, Play/Pause, Next (large touch targets)
|
||||||
|
- **Secondary Controls:** Shuffle, Repeat mode, Queue, Favorite
|
||||||
|
- **Volume:** Volume slider
|
||||||
|
- **Bottom Nav:** Still visible (can navigate away while playing)
|
||||||
|
- **Back button:** Returns to previous screen, MiniPlayer reappears
|
||||||
|
|
||||||
|
### 3.3 Full Player → Back to Browsing
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
FullPlayer[Full Player Screen] --> UserAction{User Action}
|
||||||
|
|
||||||
|
UserAction -->|Back Button / Close| HistoryBack[window.history.back]
|
||||||
|
UserAction -->|Bottom Nav Click| NavOther[Navigate to<br/>Other Screen]
|
||||||
|
|
||||||
|
HistoryBack --> PrevScreen[Return to Previous Screen<br/>in Browser History]
|
||||||
|
NavOther --> NewScreen[Navigate to New Screen]
|
||||||
|
|
||||||
|
PrevScreen --> MiniReappears[MiniPlayer Slides Up<br/>from Bottom]
|
||||||
|
NewScreen --> MiniReappears
|
||||||
|
|
||||||
|
MiniReappears --> PlaybackContinues[Playback Continues<br/>in Background]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Navigation Behavior:**
|
||||||
|
- **Back Button:** Uses browser history (`window.history.back()`) to return to the previous page
|
||||||
|
- **Expected behavior:** Returns user to the screen they were on before opening full player
|
||||||
|
- **Example:** User browsing album → clicks track → full player opens → clicks back → returns to album
|
||||||
|
|
||||||
|
**Key UX Principles:**
|
||||||
|
- **Playback Never Stops:** Navigating away from player does NOT stop playback
|
||||||
|
- **MiniPlayer Persistence:** MiniPlayer visible on ALL screens (except video/login)
|
||||||
|
- **Queue Preserved:** Current queue remains intact
|
||||||
|
- **State Restoration:** Returning to full player shows same state (position, volume, etc.)
|
||||||
|
- **Natural Navigation:** Back button behaves as expected (returns to previous page, not just closes modal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Video Playback Flows
|
||||||
|
|
||||||
|
### 4.1 Starting Video Playback
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Start[User Action] --> Action{Action Type?}
|
||||||
|
|
||||||
|
Action -->|Click Movie| MovieDetail[Movie Detail Page]
|
||||||
|
Action -->|Click Episode| EpisodeClick[Episode Click]
|
||||||
|
Action -->|Click Play Button| PlayButton[Play Button]
|
||||||
|
|
||||||
|
MovieDetail --> PlayMovie[Play Movie Button]
|
||||||
|
EpisodeClick --> PlayEpisode[Play Episode]
|
||||||
|
|
||||||
|
PlayMovie --> CheckResume{Resume<br/>Position?}
|
||||||
|
PlayEpisode --> CheckResume
|
||||||
|
|
||||||
|
CheckResume -->|Yes, >30s| ShowDialog[Resume Dialog]
|
||||||
|
CheckResume -->|No| DirectPlay[Start from Beginning]
|
||||||
|
|
||||||
|
ShowDialog --> UserChoice{User Choice}
|
||||||
|
UserChoice -->|Resume| ResumePlay[Start at Saved Position]
|
||||||
|
UserChoice -->|Start Over| DirectPlay
|
||||||
|
|
||||||
|
ResumePlay --> FullscreenVideo[Fullscreen Video Player<br/>/player/[id]]
|
||||||
|
DirectPlay --> FullscreenVideo
|
||||||
|
|
||||||
|
FullscreenVideo --> HideUI[Hide All UI:<br/>- No Bottom Nav<br/>- No MiniPlayer<br/>- Fullscreen only]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resume Dialog:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Continue Watching? │
|
||||||
|
│ │
|
||||||
|
│ [Movie Title] │
|
||||||
|
│ Resume from 12:34 / 1:45:00 │
|
||||||
|
│ │
|
||||||
|
│ [Start from Beginning] [Resume] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Video Player Screen (IR-003, IR-004, UR-003)
|
||||||
|
|
||||||
|
**Initial State (First 3 seconds):**
|
||||||
|
- Controls visible overlay
|
||||||
|
- Top bar: Back button, title
|
||||||
|
- Bottom bar: Play/Pause, seek bar, time, settings (subtitles, audio track)
|
||||||
|
- Center: Large play/pause button
|
||||||
|
|
||||||
|
**After 3 Seconds (Idle):**
|
||||||
|
- All controls fade out (500ms animation)
|
||||||
|
- Fullscreen video only
|
||||||
|
- System UI hidden (status bar, nav bar)
|
||||||
|
|
||||||
|
**User Interaction:**
|
||||||
|
- **Tap screen:** Controls reappear for 3 seconds
|
||||||
|
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
||||||
|
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
|
||||||
|
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
||||||
|
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
||||||
|
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
|
||||||
|
- **Keyboard space/K:** Toggle play/pause
|
||||||
|
- **Keyboard F:** Toggle fullscreen
|
||||||
|
- **Pinch:** Zoom (planned)
|
||||||
|
|
||||||
|
### 4.3 Exiting Video Player
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
VideoPlaying[Video Playing] --> UserAction{User Action}
|
||||||
|
|
||||||
|
UserAction -->|Back Button| StopVideo[Stop Playback]
|
||||||
|
UserAction -->|Home Button| Background[App to Background]
|
||||||
|
UserAction -->|Video Ends| VideoEnd[Playback Ended]
|
||||||
|
|
||||||
|
StopVideo --> SaveProgress[Save Progress<br/>to Local DB + Server]
|
||||||
|
VideoEnd --> SaveComplete[Mark as Watched<br/>Save Progress]
|
||||||
|
Background --> PauseVideo[Pause Video]
|
||||||
|
|
||||||
|
SaveProgress --> ExitFullscreen[Exit Fullscreen]
|
||||||
|
SaveComplete --> AutoNext{Next Episode<br/>Available?}
|
||||||
|
|
||||||
|
AutoNext -->|Yes| ShowCountdown[Show Countdown<br/>Next in 5s...]
|
||||||
|
AutoNext -->|No| ExitFullscreen
|
||||||
|
|
||||||
|
ShowCountdown --> UserCancel{User Cancels?}
|
||||||
|
UserCancel -->|Yes| ExitFullscreen
|
||||||
|
UserCancel -->|No, timeout| PlayNext[Play Next Episode]
|
||||||
|
|
||||||
|
ExitFullscreen --> RestoreUI[Restore UI:<br/>- Bottom Nav<br/>- Previous Screen]
|
||||||
|
|
||||||
|
PlayNext --> VideoPlaying
|
||||||
|
|
||||||
|
PauseVideo --> ShowNotification[Show Notification:<br/>Tap to Resume]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-Next Overlay:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [Episode Thumbnail] │
|
||||||
|
│ │
|
||||||
|
│ Next: S01E02 - Episode Title │
|
||||||
|
│ Starting in 5 seconds... │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Play Now] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Music Library Navigation Flows
|
||||||
|
|
||||||
|
### 5.1 Music Category Landing Page
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
LibraryHome[Library Home<br/>/library] --> ClickMusic[Click Music Library]
|
||||||
|
|
||||||
|
ClickMusic --> MusicLanding[Music Landing Page<br/>/library/music]
|
||||||
|
|
||||||
|
MusicLanding --> ShowCategories[Show Category Cards:<br/>- Tracks<br/>- Artists<br/>- Albums<br/>- Playlists<br/>- Genres]
|
||||||
|
|
||||||
|
ShowCategories --> UserClick{User Clicks Category}
|
||||||
|
|
||||||
|
UserClick -->|Tracks| TracksPage[All Tracks Page<br/>/library/music/tracks]
|
||||||
|
UserClick -->|Artists| ArtistsPage[Artists Grid<br/>/library/music/artists]
|
||||||
|
UserClick -->|Albums| AlbumsPage[Albums Grid<br/>/library/music/albums]
|
||||||
|
UserClick -->|Playlists| PlaylistsPage[Playlists Grid<br/>/library/music/playlists]
|
||||||
|
UserClick -->|Genres| GenresPage[Genres Browser<br/>/library/music/genres]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Category Cards:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ 🎵 │ │ 👤 │ │ 💿 │ │
|
||||||
|
│ │Track│ │Artist│ │Album│ │
|
||||||
|
│ └──────┘ └──────┘ └──────┘ │
|
||||||
|
│ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ 📝 │ │ 🎭 │ │
|
||||||
|
│ │List │ │Genre│ │
|
||||||
|
│ └──────┘ └──────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Albums View Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
|
||||||
|
|
||||||
|
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
|
||||||
|
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
||||||
|
|
||||||
|
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
||||||
|
|
||||||
|
ShowAlbum --> TrackAction{User Action}
|
||||||
|
|
||||||
|
TrackAction -->|Click Track| PlayTrack[Play Track + Queue Album]
|
||||||
|
TrackAction -->|Click Artist| NavArtist[Navigate to Artist Page]
|
||||||
|
TrackAction -->|Download Album| DownloadFlow[Download Flow]
|
||||||
|
TrackAction -->|Back Button| BackToGrid[Return to Albums Grid]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Album Detail Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [←] [♡] [⬇] │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Album Artwork │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Album Title │
|
||||||
|
│ Artist Name (clickable) │
|
||||||
|
│ 2024 • 12 tracks • 45:23 │
|
||||||
|
│ │
|
||||||
|
│ [▶ Play] [🔀 Shuffle] │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────── │
|
||||||
|
│ 1 Track Title 3:45 │
|
||||||
|
│ 2 Track Title 4:12 │
|
||||||
|
│ 3 Track Title 3:28 │
|
||||||
|
│ ... │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Artist Navigation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
ArtistsGrid[Artists Grid] --> ClickArtist[Click Artist]
|
||||||
|
|
||||||
|
ClickArtist --> ArtistPage[Artist Detail Page<br/>/library/artist/[id]]
|
||||||
|
|
||||||
|
ArtistPage --> ShowContent[Show Artist Content:<br/>- Artist Photo<br/>- Biography<br/>- Albums Grid<br/>- Top Tracks<br/>- Similar Artists]
|
||||||
|
|
||||||
|
ShowContent --> UserAction{User Action}
|
||||||
|
|
||||||
|
UserAction -->|Click Album| AlbumDetail[Album Detail Page]
|
||||||
|
UserAction -->|Play Top Tracks| PlayArtist[Play Artist Radio]
|
||||||
|
UserAction -->|Click Similar Artist| OtherArtist[Other Artist Page]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Search Flow
|
||||||
|
|
||||||
|
### 6.1 Search Page Navigation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
|
||||||
|
|
||||||
|
ClickSearch --> SearchPage[Search Page<br/>/search]
|
||||||
|
|
||||||
|
SearchPage --> EmptyState{Has Query?}
|
||||||
|
|
||||||
|
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
|
||||||
|
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
|
||||||
|
|
||||||
|
ShowPrompt --> UserTypes[User Types in Search]
|
||||||
|
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
|
||||||
|
LiveSearch --> ShowResults
|
||||||
|
|
||||||
|
ShowResults --> UserClick{User Clicks Result}
|
||||||
|
|
||||||
|
UserClick -->|Song| PlaySong[Play Song + Queue Results]
|
||||||
|
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
|
||||||
|
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
|
||||||
|
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search Page Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [🔍 Search...] [✕] │
|
||||||
|
│ │
|
||||||
|
│ Songs ──────────────────────────── │
|
||||||
|
│ ♪ Song Title - Artist 3:45 │
|
||||||
|
│ ♪ Song Title - Artist 4:12 │
|
||||||
|
│ See all (23) │
|
||||||
|
│ │
|
||||||
|
│ Albums ─────────────────────────── │
|
||||||
|
│ [Album Cover] Album Title │
|
||||||
|
│ [Album Cover] Album Title │
|
||||||
|
│ See all (8) │
|
||||||
|
│ │
|
||||||
|
│ Artists ────────────────────────── │
|
||||||
|
│ [Photo] Artist Name │
|
||||||
|
│ See all (5) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Download Flows
|
||||||
|
|
||||||
|
### 7.1 Initiating Downloads
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User[User on Album/Track Page] --> ClickDownload[Click Download Button]
|
||||||
|
|
||||||
|
ClickDownload --> CheckType{Download Type?}
|
||||||
|
|
||||||
|
CheckType -->|Single Track| DownloadTrack[Download Single File]
|
||||||
|
CheckType -->|Album| DownloadAlbum[Download All Tracks]
|
||||||
|
CheckType -->|Artist| ShowOptions[Show Options Dialog]
|
||||||
|
|
||||||
|
ShowOptions --> UserChoice{User Choice}
|
||||||
|
UserChoice -->|Discography| DownloadAll[Download All Albums]
|
||||||
|
UserChoice -->|Select Albums| AlbumPicker[Album Selection UI]
|
||||||
|
|
||||||
|
DownloadTrack --> QueueDownload[Queue in Download Manager]
|
||||||
|
DownloadAlbum --> QueueMultiple[Queue Multiple Files]
|
||||||
|
|
||||||
|
QueueDownload --> ShowProgress[Show Progress Ring<br/>on Download Button]
|
||||||
|
QueueMultiple --> ShowProgress
|
||||||
|
|
||||||
|
ShowProgress --> DownloadActive[Download Active:<br/>Button shows % complete]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Download Button States:**
|
||||||
|
```
|
||||||
|
States:
|
||||||
|
1. [⬇] Available - Gray outline
|
||||||
|
2. [○ 45%] Downloading - Blue ring progress
|
||||||
|
3. [✓] Downloaded - Green checkmark
|
||||||
|
4. [!] Failed - Red with retry option
|
||||||
|
5. [⏸] Paused - Yellow pause icon
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Managing Downloads Page
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User[User] --> NavChoice{Navigation Path}
|
||||||
|
|
||||||
|
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
||||||
|
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
||||||
|
NavChoice -->|Direct| TypeURL[Type /downloads]
|
||||||
|
|
||||||
|
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
||||||
|
HeaderIcon --> DownloadsPage
|
||||||
|
TypeURL --> DownloadsPage
|
||||||
|
|
||||||
|
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
|
||||||
|
|
||||||
|
ShowTabs --> ActiveTab{Active Tab}
|
||||||
|
|
||||||
|
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
|
||||||
|
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
|
||||||
|
|
||||||
|
ShowActive --> UserAction1{User Action}
|
||||||
|
UserAction1 -->|Pause| PauseDownload[Pause Download]
|
||||||
|
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
|
||||||
|
|
||||||
|
ShowCompleted --> UserAction2{User Action}
|
||||||
|
UserAction2 -->|Play| PlayOffline[Play from Local File]
|
||||||
|
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Navigation to Downloads:**
|
||||||
|
- **Desktop:** Click "Downloads" link in header navigation
|
||||||
|
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
||||||
|
- **Direct:** Navigate to `/downloads` route
|
||||||
|
|
||||||
|
**Downloads Page Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [←] Downloads │
|
||||||
|
│ │
|
||||||
|
│ [Active (3)] [Completed (12)] │
|
||||||
|
│ │
|
||||||
|
│ ─ Downloading ──────────────────── │
|
||||||
|
│ │
|
||||||
|
│ Album Cover Album Title │
|
||||||
|
│ Artist Name │
|
||||||
|
│ [████████░░] 80% │
|
||||||
|
│ [⏸ Pause] [✕ Cancel] │
|
||||||
|
│ │
|
||||||
|
│ Album Cover Album Title │
|
||||||
|
│ Artist Name │
|
||||||
|
│ [██░░░░░░░░] 20% │
|
||||||
|
│ [⏸ Pause] [✕ Cancel] │
|
||||||
|
│ │
|
||||||
|
│ ─ Queued ───────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ Album Cover Album Title │
|
||||||
|
│ Artist Name │
|
||||||
|
│ Waiting... │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Settings & Account Flows
|
||||||
|
|
||||||
|
### 8.1 Settings Navigation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User[User] --> NavChoice{Navigation Path}
|
||||||
|
|
||||||
|
NavChoice -->|Desktop| HeaderSettings[Header: Click Settings Link]
|
||||||
|
NavChoice -->|Mobile| OverflowMenu[Click Overflow Menu<br/>→ Settings]
|
||||||
|
NavChoice -->|Direct| TypeURL[Navigate to /settings]
|
||||||
|
|
||||||
|
HeaderSettings --> SettingsPage[Settings Page<br/>/settings]
|
||||||
|
OverflowMenu --> SettingsPage
|
||||||
|
TypeURL --> SettingsPage
|
||||||
|
|
||||||
|
SettingsPage --> ShowSections[Show Sections:<br/>- Account<br/>- Playback<br/>- Downloads<br/>- Appearance<br/>- About]
|
||||||
|
|
||||||
|
ShowSections --> UserClick{User Clicks Section}
|
||||||
|
|
||||||
|
UserClick -->|Account| AccountSettings[Account Settings:<br/>- Server URL<br/>- Username<br/>- Logout button]
|
||||||
|
UserClick -->|Playback| PlaybackSettings[Playback Settings:<br/>- Gapless playback<br/>- Volume normalization<br/>- Crossfade duration]
|
||||||
|
UserClick -->|Downloads| DownloadSettings[Download Settings:<br/>- Max concurrent<br/>- WiFi only<br/>- Storage location<br/>- Auto-cache next tracks]
|
||||||
|
UserClick -->|Appearance| AppearanceSettings[Appearance Settings:<br/>- Dark mode<br/>- Accent color]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Navigation to Settings:**
|
||||||
|
- **Desktop:** Click "Settings" link in header navigation
|
||||||
|
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
||||||
|
- **Direct:** Navigate to `/settings` route
|
||||||
|
|
||||||
|
### 8.2 Logout Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
AnyScreen[Any Screen] --> ClickLogout[Click Logout Button<br/>in Header]
|
||||||
|
|
||||||
|
ClickLogout --> ConfirmDialog[Show Confirmation:<br/>"Log out of [Server]?"]
|
||||||
|
|
||||||
|
ConfirmDialog --> UserConfirm{User Confirms?}
|
||||||
|
|
||||||
|
UserConfirm -->|No| CancelLogout[Cancel - Stay on Current Screen]
|
||||||
|
UserConfirm -->|Yes| StopPlayer[Stop Playback]
|
||||||
|
|
||||||
|
StopPlayer --> ClearToken[Delete Token from Keyring]
|
||||||
|
ClearToken --> ClearState[Clear App State:<br/>- Player state<br/>- Queue<br/>- Current screen]
|
||||||
|
|
||||||
|
ClearState --> NavLogin[Navigate to Login Screen<br/>/login]
|
||||||
|
|
||||||
|
NavLogin --> ShowLogin[Show Login Screen:<br/>- No Header<br/>- No Bottom Nav<br/>- No MiniPlayer]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logout Button Location:**
|
||||||
|
- Always visible in header user menu (logout icon)
|
||||||
|
- Accessible from any authenticated screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Background & Lock Screen Behavior
|
||||||
|
|
||||||
|
### 9.1 Audio Playback in Background (Android)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Playing[Audio Playing] --> Background{User Action}
|
||||||
|
|
||||||
|
Background -->|Home Button| AppBackground[App to Background]
|
||||||
|
Background -->|Screen Lock| ScreenLock[Screen Locked]
|
||||||
|
|
||||||
|
AppBackground --> ContinuePlay[Playback Continues]
|
||||||
|
ScreenLock --> ContinuePlay
|
||||||
|
|
||||||
|
ContinuePlay --> ShowNotification[Show Media Notification:<br/>- Artwork<br/>- Title/Artist<br/>- Play/Pause<br/>- Next/Previous]
|
||||||
|
|
||||||
|
ShowNotification --> LockScreen[Lock Screen Controls:<br/>Media Session Integration]
|
||||||
|
|
||||||
|
LockScreen --> UserInteract{User Interaction}
|
||||||
|
|
||||||
|
UserInteract -->|Tap Notification| OpenApp[Open App to Last Screen<br/>with MiniPlayer]
|
||||||
|
UserInteract -->|Lock Screen Controls| SendCommand[Send Command to Player]
|
||||||
|
UserInteract -->|BLE Headset Button| HeadsetControl[AVRCP Command]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notification Layout (Android):**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [Artwork] Song Title │
|
||||||
|
│ Artist Name │
|
||||||
|
│ Album Name │
|
||||||
|
│ │
|
||||||
|
│ [⏮] [⏸] [⏭] [✕] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Video Playback in Background
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
VideoPlaying[Video Playing] --> Background{User Action}
|
||||||
|
|
||||||
|
Background -->|Home Button| AutoPause[Automatically Pause]
|
||||||
|
Background -->|Screen Lock| AutoPause
|
||||||
|
|
||||||
|
AutoPause --> SaveProgress[Save Progress]
|
||||||
|
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
|
||||||
|
|
||||||
|
ShowNotification --> UserReturn{User Returns?}
|
||||||
|
|
||||||
|
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
|
||||||
|
UserReturn -->|Later| KeepPaused[Video Remains Paused]
|
||||||
|
|
||||||
|
ResumeVideo --> AskResume[Resume from Saved Position]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Error States & Edge Cases
|
||||||
|
|
||||||
|
### 10.1 Network Loss During Streaming
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Streaming[Streaming Audio/Video] --> LoseNetwork[Network Connection Lost]
|
||||||
|
|
||||||
|
LoseNetwork --> CheckLocal{Local Copy<br/>Available?}
|
||||||
|
|
||||||
|
CheckLocal -->|Yes| SwitchLocal[Switch to Local Playback<br/>Seamlessly]
|
||||||
|
CheckLocal -->|No| ShowBuffer[Show Buffering Spinner]
|
||||||
|
|
||||||
|
ShowBuffer --> WaitReconnect[Wait for Reconnection<br/>30 second timeout]
|
||||||
|
|
||||||
|
WaitReconnect --> Reconnect{Reconnected?}
|
||||||
|
|
||||||
|
Reconnect -->|Yes| Resume[Resume Streaming]
|
||||||
|
Reconnect -->|No| ShowError[Show Error Toast:<br/>"Unable to stream.<br/>Check connection."]
|
||||||
|
|
||||||
|
ShowError --> OfferRetry[Offer Retry Button]
|
||||||
|
ShowError --> OfferDownload[Offer "Download for Offline"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Server Unreachable
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Action[User Action Requires Server] --> TryConnect[Attempt Connection]
|
||||||
|
|
||||||
|
TryConnect --> Timeout{Connection<br/>Timeout?}
|
||||||
|
|
||||||
|
Timeout -->|Yes| ShowError[Show Error:<br/>"Server unreachable"]
|
||||||
|
Timeout -->|No| Success[Action Succeeds]
|
||||||
|
|
||||||
|
ShowError --> OfferOptions[Offer Options:<br/>- Retry<br/>- Switch to Offline Mode<br/>- Change Server]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Download Failed
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Downloading[Download in Progress] --> Failure{Failure Type?}
|
||||||
|
|
||||||
|
Failure -->|Network Error| Retry[Auto-retry<br/>with Backoff]
|
||||||
|
Failure -->|Disk Full| ShowDiskError[Show Error:<br/>"Not enough storage"]
|
||||||
|
Failure -->|Server Error| ShowServerError[Show Error:<br/>"Server error"]
|
||||||
|
|
||||||
|
Retry --> RetryCount{Retry Count<br/>< 3?}
|
||||||
|
RetryCount -->|Yes| Downloading
|
||||||
|
RetryCount -->|No| Failed[Mark as Failed]
|
||||||
|
|
||||||
|
ShowDiskError --> Failed
|
||||||
|
ShowServerError --> Failed
|
||||||
|
|
||||||
|
Failed --> UserAction[Show in Downloads:<br/>with Retry Button]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Platform-Specific UX Patterns
|
||||||
|
|
||||||
|
### 11.1 Android-Specific
|
||||||
|
|
||||||
|
**Hardware Back Button:**
|
||||||
|
- **In Full Player:** Return to previous screen, show MiniPlayer
|
||||||
|
- **In Video Player:** Stop playback, exit fullscreen
|
||||||
|
- **In Album Detail:** Return to library grid
|
||||||
|
- **At Library Home:** Exit app (show confirmation)
|
||||||
|
|
||||||
|
**System Volume Buttons:**
|
||||||
|
- **While playing audio:** Adjust playback volume
|
||||||
|
- **While controlling remote session:** Adjust remote session volume (shows session name in volume panel)
|
||||||
|
- **In menus:** Adjust system volume (default behavior)
|
||||||
|
|
||||||
|
**Share Integration:**
|
||||||
|
- Long-press album/song → Share menu
|
||||||
|
- Options: Share with other apps, Copy link
|
||||||
|
|
||||||
|
### 11.2 Linux Desktop-Specific
|
||||||
|
|
||||||
|
**Keyboard Shortcuts:**
|
||||||
|
- `Space`: Play/Pause
|
||||||
|
- `→`: Next track
|
||||||
|
- `←`: Previous track
|
||||||
|
- `/`: Focus search
|
||||||
|
- `Ctrl+Q`: Quit
|
||||||
|
|
||||||
|
**Window Behavior:**
|
||||||
|
- Minimize to tray (playback continues)
|
||||||
|
- Close window (show confirmation if playing)
|
||||||
|
- MPRIS integration for desktop media controls
|
||||||
|
|
||||||
|
**Mouse Interactions:**
|
||||||
|
- Hover over MiniPlayer: Show additional controls (volume, queue peek)
|
||||||
|
- Right-click: Context menu (Add to playlist, Go to artist, Download)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. UX Principles Summary
|
||||||
|
|
||||||
|
### 12.1 Core Principles
|
||||||
|
|
||||||
|
1. **Playback Persistence:**
|
||||||
|
- Audio playback never stops unless user explicitly stops it
|
||||||
|
- MiniPlayer visible on all screens (except video/login)
|
||||||
|
- Queue and position preserved across navigation
|
||||||
|
|
||||||
|
2. **Non-Blocking UI:**
|
||||||
|
- Downloads happen in background
|
||||||
|
- Sync operations never block user interaction
|
||||||
|
- Optimistic updates (favorite, progress) with background sync
|
||||||
|
|
||||||
|
3. **Offline-First:**
|
||||||
|
- Downloaded content works offline
|
||||||
|
- Seamless switch between online/offline
|
||||||
|
- Progress and preferences saved locally
|
||||||
|
|
||||||
|
4. **Progressive Disclosure:**
|
||||||
|
- Simple defaults, advanced options hidden
|
||||||
|
- Context menus for secondary actions
|
||||||
|
- Settings organized by category
|
||||||
|
|
||||||
|
5. **Responsive Design:**
|
||||||
|
- Mobile-first UI
|
||||||
|
- Desktop enhancements (hover states, keyboard shortcuts)
|
||||||
|
- Tablet: Grid layouts with more columns
|
||||||
|
|
||||||
|
### 12.2 Animation & Transitions
|
||||||
|
|
||||||
|
| Transition | Duration | Easing |
|
||||||
|
|------------|----------|--------|
|
||||||
|
| MiniPlayer slide up/down | 300ms | ease-out |
|
||||||
|
| Screen navigation | 200ms | ease-in-out |
|
||||||
|
| Video controls fade | 500ms | ease-out |
|
||||||
|
| Download button state change | 150ms | ease-in-out |
|
||||||
|
| Modal appear | 200ms | ease-out |
|
||||||
|
| Toast notification | 250ms | ease-in-out |
|
||||||
|
|
||||||
|
### 12.3 Touch Targets (Mobile)
|
||||||
|
|
||||||
|
| Element | Minimum Size |
|
||||||
|
|---------|--------------|
|
||||||
|
| Bottom nav buttons | 48x48 dp |
|
||||||
|
| List item (track, album) | Full width x 56 dp |
|
||||||
|
| Player controls | 56x56 dp |
|
||||||
|
| MiniPlayer | Full width x 64 dp |
|
||||||
|
| Download button | 40x40 dp |
|
||||||
|
| Favorite button | 40x40 dp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Future UX Enhancements
|
||||||
|
|
||||||
|
### 13.1 Planned Features
|
||||||
|
|
||||||
|
1. **Gesture Navigation:**
|
||||||
|
- Swipe up on MiniPlayer → Full player
|
||||||
|
- Swipe down on full player → Back to previous screen
|
||||||
|
- Swipe between tracks in full player
|
||||||
|
|
||||||
|
2. **Queue Management UI (DR-020):**
|
||||||
|
- Drag to reorder
|
||||||
|
- Swipe to remove
|
||||||
|
- Add to queue vs. Play next
|
||||||
|
|
||||||
|
3. **Sleep Timer (UR-026):**
|
||||||
|
- Accessible from full player menu
|
||||||
|
- Presets: 15min, 30min, 1hr, End of track, End of album
|
||||||
|
- Countdown visible in MiniPlayer
|
||||||
|
|
||||||
|
4. **Home Screen (UR-034):**
|
||||||
|
- Hero banner carousel
|
||||||
|
- Continue watching/listening
|
||||||
|
- Recently added
|
||||||
|
- Personalized recommendations
|
||||||
|
|
||||||
|
5. **Cast/Remote Control Enhancements:**
|
||||||
|
- Picture-in-picture for remote sessions
|
||||||
|
- Multi-room audio (play on multiple devices)
|
||||||
|
- Handoff (transfer playback to phone from TV)
|
||||||
|
|
||||||
|
### 13.2 Accessibility Enhancements
|
||||||
|
|
||||||
|
- Screen reader optimization
|
||||||
|
- High contrast mode
|
||||||
|
- Larger text option
|
||||||
|
- Voice control integration
|
||||||
|
- Haptic feedback for controls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This UX flow documentation should be updated as new features are implemented and user feedback is incorporated.
|
||||||
87
android-dev.sh
Executable file
@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 JellyTau Android Development Helper"
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
# Setup environment
|
||||||
|
echo "Setting up environment..."
|
||||||
|
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" || true
|
||||||
|
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||||
|
export NDK_HOME="$ANDROID_HOME/ndk/$(ls $ANDROID_HOME/ndk 2>/dev/null | head -1)"
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
echo -e "\n✓ Checking prerequisites..."
|
||||||
|
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
|
echo "❌ Rust not found. Please install from https://rustup.rs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v adb &> /dev/null; then
|
||||||
|
echo "❌ ADB not found. Please install Android SDK"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$ANDROID_HOME" ]; then
|
||||||
|
echo "⚠️ ANDROID_HOME not found at $ANDROID_HOME"
|
||||||
|
echo " Please install Android SDK or update the path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for connected devices
|
||||||
|
echo -e "\n📱 Connected devices:"
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# Menu
|
||||||
|
echo -e "\n📋 What would you like to do?"
|
||||||
|
echo "1) Run in development mode (hot reload)"
|
||||||
|
echo "2) Build debug APK"
|
||||||
|
echo "3) Build release APK"
|
||||||
|
echo "4) Install debug APK to device"
|
||||||
|
echo "5) Check environment"
|
||||||
|
read -p "Select option (1-5): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo -e "\n🔨 Starting development mode..."
|
||||||
|
bun run tauri android dev
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo -e "\n🔨 Building debug APK..."
|
||||||
|
bun run tauri android build --debug
|
||||||
|
echo -e "\n✅ Debug APK built at:"
|
||||||
|
echo " src-tauri/gen/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo -e "\n🔨 Building release APK..."
|
||||||
|
bun run tauri android build
|
||||||
|
echo -e "\n✅ Release APK built at:"
|
||||||
|
echo " src-tauri/gen/android/app/build/outputs/apk/release/"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
APK="src-tauri/gen/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
|
if [ -f "$APK" ]; then
|
||||||
|
echo -e "\n📲 Installing to device..."
|
||||||
|
adb install -r "$APK"
|
||||||
|
echo "✅ Installed!"
|
||||||
|
else
|
||||||
|
echo "❌ APK not found. Build it first (option 2)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
echo -e "\n🔍 Environment Check:"
|
||||||
|
echo " Rust: $(rustc --version 2>/dev/null || echo 'Not found')"
|
||||||
|
echo " Cargo: $(cargo --version 2>/dev/null || echo 'Not found')"
|
||||||
|
echo " Bun: $(bun --version 2>/dev/null || echo 'Not found')"
|
||||||
|
echo " ADB: $(adb --version 2>/dev/null | head -1 || echo 'Not found')"
|
||||||
|
echo " ANDROID_HOME: $ANDROID_HOME"
|
||||||
|
echo " NDK_HOME: $NDK_HOME"
|
||||||
|
echo ""
|
||||||
|
echo " Rust Android targets:"
|
||||||
|
rustup target list 2>/dev/null | grep android | grep installed || echo " None installed"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid option"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
24
e2e/.env.example
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# E2E Test Configuration
|
||||||
|
# Copy this file to .env and fill in your test credentials
|
||||||
|
|
||||||
|
# Jellyfin Server Configuration
|
||||||
|
TEST_SERVER_URL=https://demo.jellyfin.org/stable
|
||||||
|
TEST_SERVER_NAME=Demo Server
|
||||||
|
|
||||||
|
# Test User Credentials
|
||||||
|
TEST_USERNAME=demo
|
||||||
|
TEST_PASSWORD=
|
||||||
|
|
||||||
|
# Optional: Specific test data IDs (for testing playback, etc.)
|
||||||
|
# You can find these IDs in your Jellyfin server
|
||||||
|
TEST_MUSIC_LIBRARY_ID=
|
||||||
|
TEST_MOVIE_LIBRARY_ID=
|
||||||
|
TEST_ARTIST_ID=
|
||||||
|
TEST_ALBUM_ID=
|
||||||
|
TEST_TRACK_ID=
|
||||||
|
TEST_MOVIE_ID=
|
||||||
|
TEST_EPISODE_ID=
|
||||||
|
|
||||||
|
# Test Timeouts (milliseconds)
|
||||||
|
TEST_TIMEOUT=60000
|
||||||
|
TEST_WAIT_TIMEOUT=15000
|
||||||
376
e2e/README.md
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
# E2E Testing with WebdriverIO
|
||||||
|
|
||||||
|
End-to-end tests for JellyTau using WebdriverIO and tauri-driver. These tests run against a real Tauri app instance with an **isolated test database**.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configure test credentials (first time only)
|
||||||
|
cp e2e/.env.example e2e/.env
|
||||||
|
# Edit e2e/.env with your Jellyfin server details
|
||||||
|
|
||||||
|
# 2. Build the frontend
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# 3. Run E2E tests
|
||||||
|
bun run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Test Credentials
|
||||||
|
|
||||||
|
E2E tests use credentials from `e2e/.env` (gitignored). Copy the example file to get started:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp e2e/.env.example e2e/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
**e2e/.env** (your private file):
|
||||||
|
```bash
|
||||||
|
# Your Jellyfin test server
|
||||||
|
TEST_SERVER_URL=https://your-jellyfin.example.com
|
||||||
|
TEST_SERVER_NAME=My Test Server
|
||||||
|
|
||||||
|
# Test user credentials
|
||||||
|
TEST_USERNAME=testuser
|
||||||
|
TEST_PASSWORD=yourpassword
|
||||||
|
|
||||||
|
# Optional: Specific test data IDs
|
||||||
|
TEST_MUSIC_LIBRARY_ID=abc123
|
||||||
|
TEST_ALBUM_ID=xyz789
|
||||||
|
# ... etc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- ✅ `.env` is gitignored - your credentials stay private
|
||||||
|
- ✅ Tests fall back to Jellyfin demo server if `.env` doesn't exist
|
||||||
|
- ✅ Share `.env.example` with your team so they can set up their own
|
||||||
|
|
||||||
|
### Isolated Test Database
|
||||||
|
|
||||||
|
**Your production data is safe!** E2E tests use a completely separate database:
|
||||||
|
|
||||||
|
- **Production:** `~/.local/share/com.dtourolle.jellytau/` - Your real data ✅
|
||||||
|
- **E2E Tests:** `/tmp/jellytau-test-data/` - Isolated test data ✅
|
||||||
|
|
||||||
|
This is configured via the `JELLYTAU_DATA_DIR` environment variable in `wdio.conf.ts`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── .env.example # Template for test credentials
|
||||||
|
├── .env # Your credentials (gitignored)
|
||||||
|
├── specs/ # Test specifications
|
||||||
|
│ ├── app-launch.e2e.ts # App initialization tests
|
||||||
|
│ ├── auth.e2e.ts # Authentication flow
|
||||||
|
│ └── navigation.e2e.ts # Navigation and routing
|
||||||
|
├── pageobjects/ # Page Object Model (POM)
|
||||||
|
│ ├── BasePage.ts # Base class with common methods
|
||||||
|
│ ├── LoginPage.ts # Login page interactions
|
||||||
|
│ └── HomePage.ts # Home page interactions
|
||||||
|
└── helpers/ # Test utilities
|
||||||
|
├── testConfig.ts # Load .env configuration
|
||||||
|
└── testSetup.ts # Setup helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Object Model
|
||||||
|
|
||||||
|
Tests use the Page Object Model pattern for maintainability:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good: Using page objects
|
||||||
|
import LoginPage from "../pageobjects/LoginPage";
|
||||||
|
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||||
|
await LoginPage.login(testConfig.username, testConfig.password);
|
||||||
|
|
||||||
|
// Bad: Direct selectors in tests
|
||||||
|
await $("#server-url").setValue("https://...");
|
||||||
|
await $("button").click();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
### Using Test Configuration
|
||||||
|
|
||||||
|
Always use `testConfig` for credentials and server details:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { testConfig } from "../helpers/testConfig";
|
||||||
|
|
||||||
|
describe("My Feature", () => {
|
||||||
|
it("should test something", async () => {
|
||||||
|
// Use testConfig instead of hardcoded values
|
||||||
|
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||||
|
await LoginPage.login(testConfig.username, testConfig.password);
|
||||||
|
|
||||||
|
// Access optional test data
|
||||||
|
if (testConfig.albumId) {
|
||||||
|
// Test with specific album
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Data IDs
|
||||||
|
|
||||||
|
For tests that need specific content (albums, tracks, etc.):
|
||||||
|
|
||||||
|
1. Find the ID in your Jellyfin server (check the URL when viewing an item)
|
||||||
|
2. Add it to your `e2e/.env`:
|
||||||
|
```bash
|
||||||
|
TEST_ALBUM_ID=abc123def456
|
||||||
|
```
|
||||||
|
3. Use it in tests:
|
||||||
|
```typescript
|
||||||
|
if (testConfig.albumId) {
|
||||||
|
await browser.url(`/album/${testConfig.albumId}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { expect } from "@wdio/globals";
|
||||||
|
import LoginPage from "../pageobjects/LoginPage";
|
||||||
|
import { testConfig } from "../helpers/testConfig";
|
||||||
|
|
||||||
|
describe("Album Playback", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Login before each test
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
await LoginPage.fullLoginFlow(
|
||||||
|
testConfig.serverUrl,
|
||||||
|
testConfig.username,
|
||||||
|
testConfig.password
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should play an album", async () => {
|
||||||
|
// Skip if no test album configured
|
||||||
|
if (!testConfig.albumId) {
|
||||||
|
console.log("Skipping - no TEST_ALBUM_ID configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to album
|
||||||
|
await browser.url(`/album/${testConfig.albumId}`);
|
||||||
|
|
||||||
|
// Click play
|
||||||
|
const playButton = await $('[aria-label="Play"]');
|
||||||
|
await playButton.click();
|
||||||
|
|
||||||
|
// Verify playback started
|
||||||
|
const miniPlayer = await $(".mini-player");
|
||||||
|
expect(await miniPlayer.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests
|
||||||
|
bun run test:e2e
|
||||||
|
|
||||||
|
# Run in watch mode (development)
|
||||||
|
bun run test:e2e:dev
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
bun run test:e2e -- e2e/specs/auth.e2e.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Before Running
|
||||||
|
|
||||||
|
**Always build the frontend first:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
cd src-tauri && cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
The debug binary expects built frontend files in the `build/` directory.
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
### app-launch.e2e.ts
|
||||||
|
Basic app initialization tests:
|
||||||
|
- App launches successfully
|
||||||
|
- UI renders correctly
|
||||||
|
- Unauthenticated users redirect to login
|
||||||
|
|
||||||
|
**Status:** ✅ Working (no credentials needed)
|
||||||
|
|
||||||
|
### auth.e2e.ts
|
||||||
|
Full authentication flow:
|
||||||
|
- Server connection (2-step process)
|
||||||
|
- Login form validation
|
||||||
|
- Error handling
|
||||||
|
- Complete auth flow
|
||||||
|
|
||||||
|
**Status:** ✅ Working with any Jellyfin server
|
||||||
|
|
||||||
|
### navigation.e2e.ts
|
||||||
|
Routing and navigation:
|
||||||
|
- Protected routes
|
||||||
|
- Redirects
|
||||||
|
- Navigation after login
|
||||||
|
|
||||||
|
**Status:** ⚠️ Needs valid credentials (configure `.env`)
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### wdio.conf.ts
|
||||||
|
|
||||||
|
Main WebdriverIO configuration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
port: 4444, // tauri-driver port
|
||||||
|
maxInstances: 1, // Run tests sequentially
|
||||||
|
logLevel: "warn", // Reduce noise
|
||||||
|
framework: "mocha",
|
||||||
|
timeout: 60000, // 60s test timeout
|
||||||
|
|
||||||
|
capabilities: [{
|
||||||
|
"tauri:options": {
|
||||||
|
application: "path/to/app",
|
||||||
|
env: {
|
||||||
|
JELLYTAU_DATA_DIR: "/tmp/jellytau-test-data" // Isolated DB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `TEST_SERVER_URL` | Jellyfin server URL | `https://demo.jellyfin.org/stable` |
|
||||||
|
| `TEST_SERVER_NAME` | Server display name | `Demo Server` |
|
||||||
|
| `TEST_USERNAME` | Test user username | `demo` |
|
||||||
|
| `TEST_PASSWORD` | Test user password | `` (empty) |
|
||||||
|
| `TEST_MUSIC_LIBRARY_ID` | Music library ID | undefined |
|
||||||
|
| `TEST_ALBUM_ID` | Album ID for playback tests | undefined |
|
||||||
|
| `TEST_TRACK_ID` | Track ID for tests | undefined |
|
||||||
|
| `TEST_TIMEOUT` | Mocha test timeout (ms) | `60000` |
|
||||||
|
| `TEST_WAIT_TIMEOUT` | Element wait timeout (ms) | `15000` |
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### View Application During Tests
|
||||||
|
|
||||||
|
Tests run with a visible window. To pause and inspect:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it("debug test", async () => {
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
|
||||||
|
// Pause for 10 seconds to inspect
|
||||||
|
await browser.pause(10000);
|
||||||
|
|
||||||
|
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Logs
|
||||||
|
|
||||||
|
- **WebdriverIO logs:** Console output (set `logLevel: "info"` in config)
|
||||||
|
- **tauri-driver logs:** Stdout/stderr from driver process
|
||||||
|
- **App logs:** Check app console (if running with dev tools)
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**"Connection refused" in browser body**
|
||||||
|
- Frontend not built: Run `bun run build`
|
||||||
|
- Solution: Always build before testing
|
||||||
|
|
||||||
|
**"Element not found" errors**
|
||||||
|
- Selector might be wrong
|
||||||
|
- Element not loaded yet - add wait: `await element.waitForDisplayed()`
|
||||||
|
|
||||||
|
**"Invalid session id"**
|
||||||
|
- Normal when app closes between tests
|
||||||
|
- Each test file gets a fresh app instance
|
||||||
|
|
||||||
|
**Tests fail with "no .env file"**
|
||||||
|
- Copy `e2e/.env.example` to `e2e/.env`
|
||||||
|
- Configure your Jellyfin server details
|
||||||
|
|
||||||
|
**Database still using production data**
|
||||||
|
- Check `wdio.conf.ts` has `JELLYTAU_DATA_DIR` env var
|
||||||
|
- Rebuild app: `cd src-tauri && cargo build`
|
||||||
|
|
||||||
|
## Platform Support
|
||||||
|
|
||||||
|
### Supported
|
||||||
|
|
||||||
|
- ✅ **Linux** - Primary development platform
|
||||||
|
- ✅ **Windows** - Supported (paths auto-detected)
|
||||||
|
- ✅ **macOS** - Supported (paths auto-detected)
|
||||||
|
|
||||||
|
### Not Supported
|
||||||
|
|
||||||
|
- ❌ **Android** - E2E testing requires Appium + emulators (out of scope)
|
||||||
|
- Desktop tests cover 90% of app logic anyway
|
||||||
|
|
||||||
|
## Team Collaboration
|
||||||
|
|
||||||
|
### Sharing Test Configuration
|
||||||
|
|
||||||
|
**DO:**
|
||||||
|
- ✅ Commit `e2e/.env.example` with template values
|
||||||
|
- ✅ Update README when adding new test data requirements
|
||||||
|
- ✅ Use descriptive variable names in `.env.example`
|
||||||
|
|
||||||
|
**DON'T:**
|
||||||
|
- ❌ Commit `e2e/.env` with real credentials
|
||||||
|
- ❌ Hardcode server URLs in test files
|
||||||
|
- ❌ Skip authentication in tests (always test full flows)
|
||||||
|
|
||||||
|
### Setting Up for a New Team Member
|
||||||
|
|
||||||
|
1. **Clone repo**
|
||||||
|
2. **Copy env template:** `cp e2e/.env.example e2e/.env`
|
||||||
|
3. **Configure credentials:** Edit `e2e/.env` with your Jellyfin server
|
||||||
|
4. **Build frontend:** `bun run build`
|
||||||
|
5. **Run tests:** `bun run test:e2e`
|
||||||
|
|
||||||
|
That's it! No shared credentials needed.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use testConfig:** Never hardcode credentials
|
||||||
|
2. **Use Page Objects:** Keep selectors out of test specs
|
||||||
|
3. **Wait for Elements:** Always use `.waitForDisplayed()`
|
||||||
|
4. **Independent Tests:** Each test should work standalone
|
||||||
|
5. **Skip Gracefully:** Check for optional test data before using
|
||||||
|
6. **Build First:** Always `bun run build` before running tests
|
||||||
|
7. **Clear Names:** Use descriptive `describe` and `it` blocks
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Add more page objects (Player, Library, Queue, Settings)
|
||||||
|
- [ ] Create test data fixtures
|
||||||
|
- [ ] Add visual regression testing
|
||||||
|
- [ ] Mock Jellyfin API for faster, more reliable tests
|
||||||
|
- [ ] CI/CD integration (GitHub Actions)
|
||||||
|
- [ ] Test report generation
|
||||||
|
- [ ] Screenshot capture on failure
|
||||||
|
- [ ] Video recording of test runs
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [WebdriverIO Documentation](https://webdriver.io/)
|
||||||
|
- [Tauri Testing Guide](https://v2.tauri.app/develop/tests/webdriver/)
|
||||||
|
- [tauri-driver GitHub](https://github.com/tauri-apps/tauri/tree/dev/tooling/webdriver)
|
||||||
|
- [Mocha Documentation](https://mochajs.org/)
|
||||||
|
- [Page Object Model Pattern](https://webdriver.io/docs/pageobjects/)
|
||||||
105
e2e/helpers/testConfig.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test configuration loaded from .env file
|
||||||
|
*/
|
||||||
|
export interface TestConfig {
|
||||||
|
serverUrl: string;
|
||||||
|
serverName: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
musicLibraryId?: string;
|
||||||
|
movieLibraryId?: string;
|
||||||
|
artistId?: string;
|
||||||
|
albumId?: string;
|
||||||
|
trackId?: string;
|
||||||
|
movieId?: string;
|
||||||
|
episodeId?: string;
|
||||||
|
timeout: number;
|
||||||
|
waitTimeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load test configuration from .env file
|
||||||
|
* Falls back to demo server if .env doesn't exist
|
||||||
|
*/
|
||||||
|
export function loadTestConfig(): TestConfig {
|
||||||
|
const envPath = path.join(__dirname, "..", ".env");
|
||||||
|
const config: TestConfig = {
|
||||||
|
serverUrl: "https://demo.jellyfin.org/stable",
|
||||||
|
serverName: "Demo Server",
|
||||||
|
username: "demo",
|
||||||
|
password: "",
|
||||||
|
timeout: 60000,
|
||||||
|
waitTimeout: 15000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to load .env file
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
const envContent = fs.readFileSync(envPath, "utf-8");
|
||||||
|
const lines = envContent.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if (line.trim().startsWith("#") || !line.trim()) continue;
|
||||||
|
|
||||||
|
const [key, ...valueParts] = line.split("=");
|
||||||
|
const value = valueParts.join("=").trim();
|
||||||
|
|
||||||
|
switch (key.trim()) {
|
||||||
|
case "TEST_SERVER_URL":
|
||||||
|
if (value) config.serverUrl = value;
|
||||||
|
break;
|
||||||
|
case "TEST_SERVER_NAME":
|
||||||
|
if (value) config.serverName = value;
|
||||||
|
break;
|
||||||
|
case "TEST_USERNAME":
|
||||||
|
if (value) config.username = value;
|
||||||
|
break;
|
||||||
|
case "TEST_PASSWORD":
|
||||||
|
config.password = value; // Can be empty
|
||||||
|
break;
|
||||||
|
case "TEST_MUSIC_LIBRARY_ID":
|
||||||
|
if (value) config.musicLibraryId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_MOVIE_LIBRARY_ID":
|
||||||
|
if (value) config.movieLibraryId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_ARTIST_ID":
|
||||||
|
if (value) config.artistId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_ALBUM_ID":
|
||||||
|
if (value) config.albumId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_TRACK_ID":
|
||||||
|
if (value) config.trackId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_MOVIE_ID":
|
||||||
|
if (value) config.movieId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_EPISODE_ID":
|
||||||
|
if (value) config.episodeId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_TIMEOUT":
|
||||||
|
if (value) config.timeout = parseInt(value, 10);
|
||||||
|
break;
|
||||||
|
case "TEST_WAIT_TIMEOUT":
|
||||||
|
if (value) config.waitTimeout = parseInt(value, 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"⚠️ No e2e/.env file found. Using demo server credentials."
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
" Copy e2e/.env.example to e2e/.env and configure your test server."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance
|
||||||
|
export const testConfig = loadTestConfig();
|
||||||
53
e2e/helpers/testSetup.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the JellyTau database and cache before tests
|
||||||
|
* This ensures each test run starts with a fresh state
|
||||||
|
*/
|
||||||
|
export function clearAppData() {
|
||||||
|
const appDataDir = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
".local/share/com.dtourolle.jellytau"
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(appDataDir)) {
|
||||||
|
// Remove database file
|
||||||
|
const dbPath = path.join(appDataDir, "jellytau.db");
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
fs.unlinkSync(dbPath);
|
||||||
|
console.log("Cleared test database");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any cache files if needed
|
||||||
|
// Add more cleanup as needed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to clear app data:", error);
|
||||||
|
// Don't fail tests if cleanup fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for element with retries
|
||||||
|
* Useful for elements that might take time to appear
|
||||||
|
*/
|
||||||
|
export async function waitForElement(
|
||||||
|
selector: string,
|
||||||
|
timeout: number = 15000,
|
||||||
|
retries: number = 3
|
||||||
|
): Promise<WebdriverIO.Element> {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
const element = await $(selector);
|
||||||
|
await element.waitForDisplayed({ timeout });
|
||||||
|
return element;
|
||||||
|
} catch (error) {
|
||||||
|
if (i === retries - 1) throw error;
|
||||||
|
await browser.pause(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Element ${selector} not found after ${retries} retries`);
|
||||||
|
}
|
||||||
31
e2e/pageobjects/BasePage.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export default class BasePage {
|
||||||
|
async waitForElement(selector: string, timeout: number = 10000) {
|
||||||
|
const element = await $(selector);
|
||||||
|
await element.waitForDisplayed({ timeout });
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickElement(selector: string) {
|
||||||
|
const element = await this.waitForElement(selector);
|
||||||
|
await element.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterText(selector: string, text: string) {
|
||||||
|
const element = await this.waitForElement(selector);
|
||||||
|
await element.setValue(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getText(selector: string): Promise<string> {
|
||||||
|
const element = await this.waitForElement(selector);
|
||||||
|
return await element.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
async isElementDisplayed(selector: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const element = await $(selector);
|
||||||
|
return await element.isDisplayed();
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
e2e/pageobjects/HomePage.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import BasePage from "./BasePage";
|
||||||
|
|
||||||
|
class HomePage extends BasePage {
|
||||||
|
// Selectors
|
||||||
|
get loadingSpinner() {
|
||||||
|
return $(".animate-spin");
|
||||||
|
}
|
||||||
|
|
||||||
|
get browseLibrariesButton() {
|
||||||
|
return $("button*=Browse all libraries");
|
||||||
|
}
|
||||||
|
|
||||||
|
get offlineBanner() {
|
||||||
|
return $(".bg-amber-600\\/90");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carousel sections
|
||||||
|
get heroSection() {
|
||||||
|
return $("div"); // Hero banner would need specific selector
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async waitForHomePageLoad(timeout: number = 15000) {
|
||||||
|
// Wait for loading spinner to disappear
|
||||||
|
try {
|
||||||
|
await this.loadingSpinner.waitForDisplayed({ timeout: 5000 });
|
||||||
|
await this.loadingSpinner.waitForDisplayed({ timeout, reverse: true });
|
||||||
|
} catch {
|
||||||
|
// Spinner might not appear if page loads quickly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOffline(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.offlineBanner.isDisplayed();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickBrowseLibraries() {
|
||||||
|
await this.browseLibrariesButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasContent(): Promise<boolean> {
|
||||||
|
// Check if browse button exists (indicates loaded state)
|
||||||
|
try {
|
||||||
|
return await this.browseLibrariesButton.isExisting();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new HomePage();
|
||||||
116
e2e/pageobjects/LoginPage.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import BasePage from "./BasePage";
|
||||||
|
|
||||||
|
class LoginPage extends BasePage {
|
||||||
|
// Selectors
|
||||||
|
get pageTitle() {
|
||||||
|
return $("h1");
|
||||||
|
}
|
||||||
|
|
||||||
|
get serverUrlInput() {
|
||||||
|
return $("#server-url");
|
||||||
|
}
|
||||||
|
|
||||||
|
get connectButton() {
|
||||||
|
return $('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
get usernameInput() {
|
||||||
|
return $("#username");
|
||||||
|
}
|
||||||
|
|
||||||
|
get passwordInput() {
|
||||||
|
return $("#password");
|
||||||
|
}
|
||||||
|
|
||||||
|
get signInButton() {
|
||||||
|
return $('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
get errorMessage() {
|
||||||
|
return $(".bg-red-900\\/50");
|
||||||
|
}
|
||||||
|
|
||||||
|
get backButton() {
|
||||||
|
return $("button*=Back");
|
||||||
|
}
|
||||||
|
|
||||||
|
get serverNameDisplay() {
|
||||||
|
return $('p.text-\\[var\\(--color-jellyfin\\)\\]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async waitForLoginPage(timeout: number = 10000) {
|
||||||
|
await this.serverUrlInput.waitForDisplayed({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterServerUrl(url: string) {
|
||||||
|
await this.serverUrlInput.setValue(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickConnect() {
|
||||||
|
await this.connectButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectToServer(url: string) {
|
||||||
|
await this.enterServerUrl(url);
|
||||||
|
await this.clickConnect();
|
||||||
|
|
||||||
|
// Wait for transition to login form
|
||||||
|
await this.usernameInput.waitForDisplayed({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterUsername(username: string) {
|
||||||
|
await this.usernameInput.setValue(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterPassword(password: string) {
|
||||||
|
await this.passwordInput.setValue(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickSignIn() {
|
||||||
|
await this.signInButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
await this.enterUsername(username);
|
||||||
|
await this.enterPassword(password);
|
||||||
|
await this.clickSignIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fullLoginFlow(serverUrl: string, username: string, password: string) {
|
||||||
|
await this.waitForLoginPage();
|
||||||
|
await this.connectToServer(serverUrl);
|
||||||
|
await this.login(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOnServerStep(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.serverUrlInput.isDisplayed();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOnLoginStep(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.usernameInput.isDisplayed();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getErrorMessage(): Promise<string> {
|
||||||
|
await this.errorMessage.waitForDisplayed({ timeout: 5000 });
|
||||||
|
return await this.errorMessage.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasError(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.errorMessage.isDisplayed();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LoginPage();
|
||||||
39
e2e/specs/app-launch.e2e.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { expect } from "@wdio/globals";
|
||||||
|
|
||||||
|
describe("Application Launch", () => {
|
||||||
|
it("should launch the application", async () => {
|
||||||
|
// Wait for body element to appear
|
||||||
|
const body = await $("body");
|
||||||
|
await body.waitForDisplayed({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Verify app launched successfully
|
||||||
|
expect(await body.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the main app container", async () => {
|
||||||
|
// The app has a root div with specific classes
|
||||||
|
const appContainer = await $("div.h-screen.bg-\\[var\\(--color-background\\)\\]");
|
||||||
|
|
||||||
|
// Verify the main container exists
|
||||||
|
expect(await appContainer.isExisting()).toBe(true);
|
||||||
|
expect(await appContainer.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show JellyTau branding", async () => {
|
||||||
|
// The app should show JellyTau title on login page (default state)
|
||||||
|
const title = await $("h1");
|
||||||
|
await title.waitForDisplayed({ timeout: 10000 });
|
||||||
|
|
||||||
|
const titleText = await title.getText();
|
||||||
|
expect(titleText).toContain("JellyTau");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redirect unauthenticated users to login", async () => {
|
||||||
|
// Wait for login page elements to appear
|
||||||
|
const serverUrlInput = await $("#server-url");
|
||||||
|
await serverUrlInput.waitForDisplayed({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify we're on the login page
|
||||||
|
expect(await serverUrlInput.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
145
e2e/specs/auth.e2e.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { expect } from "@wdio/globals";
|
||||||
|
import LoginPage from "../pageobjects/LoginPage";
|
||||||
|
import { testConfig } from "../helpers/testConfig";
|
||||||
|
|
||||||
|
describe("Authentication Flow", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Each test starts fresh - app should redirect to login
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Server Connection", () => {
|
||||||
|
it("should display the server connection form", async () => {
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
expect(await LoginPage.pageTitle.getText()).toContain("JellyTau");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show server URL input field", async () => {
|
||||||
|
const serverInput = await LoginPage.serverUrlInput;
|
||||||
|
|
||||||
|
expect(await serverInput.isDisplayed()).toBe(true);
|
||||||
|
expect(await serverInput.getAttribute("placeholder")).toContain("jellyfin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have a disabled connect button when URL is empty", async () => {
|
||||||
|
const connectButton = await LoginPage.connectButton;
|
||||||
|
|
||||||
|
// Button should be disabled when input is empty
|
||||||
|
expect(await connectButton.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable connect button when URL is entered", async () => {
|
||||||
|
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||||
|
|
||||||
|
const connectButton = await LoginPage.connectButton;
|
||||||
|
expect(await connectButton.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error for invalid server URL", async () => {
|
||||||
|
await LoginPage.enterServerUrl("not-a-valid-url");
|
||||||
|
await LoginPage.clickConnect();
|
||||||
|
|
||||||
|
// Wait for error to appear
|
||||||
|
await browser.pause(2000);
|
||||||
|
|
||||||
|
expect(await LoginPage.hasError()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition to login form on successful connection", async () => {
|
||||||
|
// Using configured test server
|
||||||
|
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||||
|
|
||||||
|
// Should now be on login step
|
||||||
|
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User Login", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Connect to configured test server before each login test
|
||||||
|
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display login form after server connection", async () => {
|
||||||
|
expect(await LoginPage.usernameInput.isDisplayed()).toBe(true);
|
||||||
|
expect(await LoginPage.passwordInput.isDisplayed()).toBe(true);
|
||||||
|
expect(await LoginPage.signInButton.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show server information", async () => {
|
||||||
|
// Server name and URL should be displayed
|
||||||
|
const serverName = await LoginPage.serverNameDisplay;
|
||||||
|
expect(await serverName.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have back button to return to server selection", async () => {
|
||||||
|
expect(await LoginPage.backButton.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
await LoginPage.backButton.click();
|
||||||
|
await browser.pause(500);
|
||||||
|
|
||||||
|
// Should be back on server step
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable sign in button when username is empty", async () => {
|
||||||
|
const signInButton = await LoginPage.signInButton;
|
||||||
|
expect(await signInButton.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable sign in button when username is entered", async () => {
|
||||||
|
await LoginPage.enterUsername("demo");
|
||||||
|
|
||||||
|
const signInButton = await LoginPage.signInButton;
|
||||||
|
expect(await signInButton.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error for invalid credentials", async () => {
|
||||||
|
await LoginPage.login("invalid-user", "wrong-password");
|
||||||
|
|
||||||
|
// Wait for error
|
||||||
|
await browser.pause(2000);
|
||||||
|
|
||||||
|
expect(await LoginPage.hasError()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable this test by configuring e2e/.env with valid credentials
|
||||||
|
it.skip("should successfully login with valid credentials", async () => {
|
||||||
|
await LoginPage.login(testConfig.username, testConfig.password);
|
||||||
|
|
||||||
|
// Wait for redirect to home page
|
||||||
|
await browser.pause(3000);
|
||||||
|
|
||||||
|
// Should redirect away from login page
|
||||||
|
const currentUrl = await browser.getUrl();
|
||||||
|
expect(currentUrl).not.toContain("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Full Authentication Flow", () => {
|
||||||
|
it("should complete full auth flow with test server", async () => {
|
||||||
|
// Test the complete flow
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
|
||||||
|
// Step 1: Enter server URL
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||||
|
await LoginPage.clickConnect();
|
||||||
|
|
||||||
|
// Wait for transition
|
||||||
|
await browser.pause(2000);
|
||||||
|
|
||||||
|
// Step 2: Should be on login form
|
||||||
|
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
||||||
|
|
||||||
|
// Step 3: Enter credentials
|
||||||
|
await LoginPage.enterUsername(testConfig.username);
|
||||||
|
await LoginPage.enterPassword(testConfig.password);
|
||||||
|
|
||||||
|
// Verify form is filled
|
||||||
|
const username = await LoginPage.usernameInput.getValue();
|
||||||
|
expect(username).toBe(testConfig.username);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
39
e2e/specs/navigation.e2e.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { expect } from "@wdio/globals";
|
||||||
|
import LoginPage from "../pageobjects/LoginPage";
|
||||||
|
import HomePage from "../pageobjects/HomePage";
|
||||||
|
import { testConfig } from "../helpers/testConfig";
|
||||||
|
|
||||||
|
describe("Navigation", () => {
|
||||||
|
it("should redirect unauthenticated users to login", async () => {
|
||||||
|
// App should automatically redirect to login when not authenticated
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prevent direct access to protected routes", async () => {
|
||||||
|
// Try to navigate to a protected route
|
||||||
|
await browser.url("http://localhost:4444/session/fake-session-id/url");
|
||||||
|
await browser.pause(1000);
|
||||||
|
|
||||||
|
// Should redirect back to login
|
||||||
|
await LoginPage.waitForLoginPage(5000);
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This test requires valid authentication - configure e2e/.env to enable
|
||||||
|
it.skip("should allow navigation after login", async () => {
|
||||||
|
// Login first
|
||||||
|
await LoginPage.fullLoginFlow(
|
||||||
|
testConfig.serverUrl,
|
||||||
|
testConfig.username,
|
||||||
|
testConfig.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for home page
|
||||||
|
await HomePage.waitForHomePageLoad();
|
||||||
|
|
||||||
|
// Should be able to navigate
|
||||||
|
expect(await HomePage.hasContent()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
9843
package-lock.json
generated
Normal file
59
package.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "jellytau",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:e2e": "wdio run ./wdio.conf.ts",
|
||||||
|
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||||
|
"test:all": "./scripts/test-all.sh",
|
||||||
|
"test:rust": "./scripts/test-rust.sh",
|
||||||
|
"android:build": "./scripts/build-android.sh",
|
||||||
|
"android:build:release": "./scripts/build-android.sh release",
|
||||||
|
"android:deploy": "./scripts/deploy-android.sh",
|
||||||
|
"android:dev": "./scripts/build-and-deploy.sh",
|
||||||
|
"android:check": "./scripts/check-android.sh",
|
||||||
|
"android:logs": "./scripts/logcat.sh",
|
||||||
|
"clean": "./scripts/clean.sh",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
|
"svelte-dnd-action": "^0.9.69"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
|
"@sveltejs/kit": "^2.9.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"@vitest/ui": "^4.0.16",
|
||||||
|
"@wdio/cli": "^9.5.0",
|
||||||
|
"@wdio/local-runner": "^9.5.0",
|
||||||
|
"@wdio/mocha-framework": "^9.5.0",
|
||||||
|
"@wdio/spec-reporter": "^9.5.0",
|
||||||
|
"happy-dom": "^20.0.11",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"svelte": "^5.47.1",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vitest": "^4.0.16",
|
||||||
|
"webdriverio": "^9.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
scripts/README.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# Development Scripts
|
||||||
|
|
||||||
|
Collection of utility scripts for building, testing, and deploying JellyTau.
|
||||||
|
|
||||||
|
## Testing Scripts
|
||||||
|
|
||||||
|
### `test-all.sh`
|
||||||
|
Run all tests (frontend + Rust backend).
|
||||||
|
```bash
|
||||||
|
./scripts/test-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### `test-frontend.sh`
|
||||||
|
Run frontend tests only.
|
||||||
|
```bash
|
||||||
|
./scripts/test-frontend.sh # Run all tests
|
||||||
|
./scripts/test-frontend.sh --watch # Watch mode
|
||||||
|
./scripts/test-frontend.sh --ui # Open UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### `test-rust.sh`
|
||||||
|
Run Rust tests only.
|
||||||
|
```bash
|
||||||
|
./scripts/test-rust.sh # Run all tests
|
||||||
|
./scripts/test-rust.sh -- --nocapture # Show println! output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android Scripts
|
||||||
|
|
||||||
|
### `build-android.sh`
|
||||||
|
Build the Android APK.
|
||||||
|
```bash
|
||||||
|
./scripts/build-android.sh # Debug build
|
||||||
|
./scripts/build-android.sh release # Release build
|
||||||
|
```
|
||||||
|
|
||||||
|
### `deploy-android.sh`
|
||||||
|
Install APK on connected Android device.
|
||||||
|
```bash
|
||||||
|
./scripts/deploy-android.sh # Deploy debug APK
|
||||||
|
./scripts/deploy-android.sh release # Deploy release APK
|
||||||
|
```
|
||||||
|
|
||||||
|
### `build-and-deploy.sh`
|
||||||
|
Build and deploy in one command.
|
||||||
|
```bash
|
||||||
|
./scripts/build-and-deploy.sh # Build + deploy debug
|
||||||
|
./scripts/build-and-deploy.sh release # Build + deploy release
|
||||||
|
```
|
||||||
|
|
||||||
|
### `check-android.sh`
|
||||||
|
Check Android development environment setup.
|
||||||
|
```bash
|
||||||
|
./scripts/check-android.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### `logcat.sh`
|
||||||
|
View Android logcat filtered for the app.
|
||||||
|
```bash
|
||||||
|
./scripts/logcat.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utility Scripts
|
||||||
|
|
||||||
|
### `clean.sh`
|
||||||
|
Clean all build artifacts.
|
||||||
|
```bash
|
||||||
|
./scripts/clean.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## NPM Script Aliases
|
||||||
|
|
||||||
|
You can also run these via npm/bun:
|
||||||
|
```bash
|
||||||
|
bun run test:all # All tests
|
||||||
|
bun run test:rust # Rust tests
|
||||||
|
bun run android:build # Build Android APK
|
||||||
|
bun run android:deploy # Deploy to device
|
||||||
|
bun run android:dev # Build + deploy debug
|
||||||
|
bun run android:check # Check environment
|
||||||
|
bun run clean # Clean artifacts
|
||||||
|
```
|
||||||
17
scripts/build-and-deploy.sh
Executable file
@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build and deploy Android APK in one command
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
echo "🚀 Build and Deploy Android APK"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build APK
|
||||||
|
./scripts/build-android.sh "$BUILD_TYPE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Deploy APK
|
||||||
|
./scripts/deploy-android.sh "$BUILD_TYPE"
|
||||||
40
scripts/build-android.sh
Executable file
@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build Android APK
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Source Rust environment
|
||||||
|
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Set Android environment variables
|
||||||
|
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||||
|
export NDK_HOME="$ANDROID_HOME/ndk/$(ls "$ANDROID_HOME/ndk" | head -1)"
|
||||||
|
|
||||||
|
echo "🤖 Building Android APK..."
|
||||||
|
echo "Android SDK: $ANDROID_HOME"
|
||||||
|
echo "NDK: $NDK_HOME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build type: debug or release (default: debug)
|
||||||
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
# Step 1: Sync Android source files
|
||||||
|
echo "🔄 Syncing Android sources..."
|
||||||
|
./scripts/sync-android-sources.sh
|
||||||
|
|
||||||
|
# Step 2: Build the frontend first to avoid dev server issues
|
||||||
|
echo "🎨 Building frontend..."
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Step 2: Build Android APK
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
echo "📦 Building release APK..."
|
||||||
|
bun run tauri android build --apk true
|
||||||
|
else
|
||||||
|
echo "📦 Building debug APK..."
|
||||||
|
bun run tauri android build --apk true --debug
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ APK build complete!"
|
||||||
|
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
||||||
55
scripts/check-android.sh
Executable file
@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Check Android development environment
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔍 Checking Android development environment..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check ADB
|
||||||
|
if command -v adb &> /dev/null; then
|
||||||
|
echo "✅ ADB installed: $(adb version | head -1)"
|
||||||
|
else
|
||||||
|
echo "❌ ADB not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Android SDK
|
||||||
|
if [ -d "$HOME/Android/Sdk" ]; then
|
||||||
|
echo "✅ Android SDK found at: $HOME/Android/Sdk"
|
||||||
|
else
|
||||||
|
echo "❌ Android SDK not found at: $HOME/Android/Sdk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check NDK
|
||||||
|
if [ -d "$HOME/Android/Sdk/ndk" ]; then
|
||||||
|
NDK_VERSION=$(ls "$HOME/Android/Sdk/ndk" | head -1)
|
||||||
|
echo "✅ NDK found: $NDK_VERSION"
|
||||||
|
else
|
||||||
|
echo "❌ NDK not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Rust
|
||||||
|
if command -v rustc &> /dev/null; then
|
||||||
|
echo "✅ Rust installed: $(rustc --version)"
|
||||||
|
else
|
||||||
|
echo "❌ Rust not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Cargo
|
||||||
|
if command -v cargo &> /dev/null; then
|
||||||
|
echo "✅ Cargo installed: $(cargo --version)"
|
||||||
|
else
|
||||||
|
echo "❌ Cargo not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for connected devices
|
||||||
|
echo ""
|
||||||
|
echo "📱 Connected Android devices:"
|
||||||
|
if adb devices | grep -q "device$"; then
|
||||||
|
adb devices | grep "device$"
|
||||||
|
else
|
||||||
|
echo "⚠️ No devices connected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Environment check complete!"
|
||||||
84
scripts/check-req-coverage.sh
Executable file
@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Requirements Coverage Checker
|
||||||
|
# Extracts @req tags from codebase and compares with README.md
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REQUIREMENTS_FILE="README.md"
|
||||||
|
SOURCE_DIRS="src-tauri/ src/"
|
||||||
|
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " Requirements Coverage Report"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Extract requirement IDs from README.md (UR-, IR-, DR-, JA-)
|
||||||
|
echo "📊 Scanning requirements from $REQUIREMENTS_FILE..."
|
||||||
|
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" "$REQUIREMENTS_FILE" | \
|
||||||
|
sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | \
|
||||||
|
sort -u)
|
||||||
|
|
||||||
|
total_reqs=$(echo "$requirements" | wc -l)
|
||||||
|
implemented=0
|
||||||
|
partial=0
|
||||||
|
planned=0
|
||||||
|
missing=0
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Category Breakdown:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
for category in UR IR DR JA; do
|
||||||
|
cat_count=$(echo "$requirements" | grep "^$category-" | wc -l)
|
||||||
|
printf "%-4s %3d requirements\n" "$category:" "$cat_count"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Implementation Status:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
for req in $requirements; do
|
||||||
|
# Count full implementations
|
||||||
|
full_count=$(grep -r "@req: $req" $SOURCE_DIRS 2>/dev/null | grep -v "@req-partial" | grep -v "@req-planned" | wc -l)
|
||||||
|
|
||||||
|
# Count partial implementations
|
||||||
|
partial_count=$(grep -r "@req-partial: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
# Count planned
|
||||||
|
planned_count=$(grep -r "@req-planned: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
if [ "$full_count" -gt 0 ]; then
|
||||||
|
echo "✅ $req: $full_count implementation(s)"
|
||||||
|
((implemented++))
|
||||||
|
elif [ "$partial_count" -gt 0 ]; then
|
||||||
|
echo "🔶 $req: $partial_count partial implementation(s)"
|
||||||
|
((partial++))
|
||||||
|
elif [ "$planned_count" -gt 0 ]; then
|
||||||
|
echo "📋 $req: Planned (not yet implemented)"
|
||||||
|
((planned++))
|
||||||
|
else
|
||||||
|
echo "❌ $req: No implementation found"
|
||||||
|
((missing++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
printf "Total Requirements: %3d\n" "$total_reqs"
|
||||||
|
printf "✅ Fully Implemented: %3d (%.0f%%)\n" "$implemented" "$(echo "scale=0; $implemented * 100 / $total_reqs" | bc)"
|
||||||
|
printf "🔶 Partially Implemented: %3d (%.0f%%)\n" "$partial" "$(echo "scale=0; $partial * 100 / $total_reqs" | bc)"
|
||||||
|
printf "📋 Planned: %3d (%.0f%%)\n" "$planned" "$(echo "scale=0; $planned * 100 / $total_reqs" | bc)"
|
||||||
|
printf "❌ Missing: %3d (%.0f%%)\n" "$missing" "$(echo "scale=0; $missing * 100 / $total_reqs" | bc)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Exit code based on missing critical requirements
|
||||||
|
if [ "$missing" -gt 0 ]; then
|
||||||
|
echo "⚠️ Warning: $missing requirements have no implementation"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✨ All requirements have implementations!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
40
scripts/check-test-coverage.sh
Executable file
@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Test Coverage Report
|
||||||
|
# Links test requirements to implementations
|
||||||
|
#
|
||||||
|
|
||||||
|
echo "Test Coverage Report"
|
||||||
|
echo "===================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
test_reqs=$(grep -rh "@req-test:" src-tauri/ 2>/dev/null | \
|
||||||
|
sed 's/.*@req-test: \([A-Z][A-Z]-[0-9]*\).*/\1/' | \
|
||||||
|
sort -u)
|
||||||
|
|
||||||
|
total_tests=0
|
||||||
|
covered=0
|
||||||
|
uncovered=0
|
||||||
|
|
||||||
|
for req in $test_reqs; do
|
||||||
|
test_count=$(grep -r "@req-test: $req" src-tauri/ 2>/dev/null | wc -l)
|
||||||
|
impl_count=$(grep -r "@req: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
((total_tests++))
|
||||||
|
|
||||||
|
if [ "$test_count" -gt 0 ] && [ "$impl_count" -gt 0 ]; then
|
||||||
|
echo "✅ $req: $test_count test(s), $impl_count implementation(s)"
|
||||||
|
((covered++))
|
||||||
|
elif [ "$impl_count" -eq 0 ]; then
|
||||||
|
echo "⚠️ $req: $test_count test(s) but no implementation"
|
||||||
|
((uncovered++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
printf "Total Test Requirements: %3d\n" "$total_tests"
|
||||||
|
printf "✅ With Implementation: %3d (%.0f%%)\n" "$covered" "$(echo "scale=0; $covered * 100 / $total_tests" | bc)"
|
||||||
|
printf "⚠️ No Implementation: %3d (%.0f%%)\n" "$uncovered" "$(echo "scale=0; $uncovered * 100 / $total_tests" | bc)"
|
||||||
|
echo ""
|
||||||
40
scripts/clean.sh
Executable file
@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Clean build artifacts
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧹 Cleaning build artifacts..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Clean frontend
|
||||||
|
if [ -d "node_modules/.cache" ]; then
|
||||||
|
echo "Cleaning Vite cache..."
|
||||||
|
rm -rf node_modules/.cache
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d ".svelte-kit" ]; then
|
||||||
|
echo "Cleaning SvelteKit build..."
|
||||||
|
rm -rf .svelte-kit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "build" ]; then
|
||||||
|
echo "Cleaning build directory..."
|
||||||
|
rm -rf build
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean Rust
|
||||||
|
echo "Cleaning Rust target..."
|
||||||
|
cd src-tauri
|
||||||
|
cargo clean
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Clean Android
|
||||||
|
if [ -d "src-tauri/gen/android" ]; then
|
||||||
|
echo "Cleaning Android build..."
|
||||||
|
cd src-tauri/gen/android
|
||||||
|
./gradlew clean 2>/dev/null || true
|
||||||
|
cd ../../..
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Clean complete!"
|
||||||
37
scripts/deploy-android.sh
Executable file
@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy APK to connected Android device
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "📱 Deploying to Android device..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if device is connected
|
||||||
|
if ! adb devices | grep -q "device$"; then
|
||||||
|
echo "❌ No Android device connected!"
|
||||||
|
echo "Please connect a device or start an emulator."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build type: debug or release (default: debug)
|
||||||
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
||||||
|
else
|
||||||
|
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if APK exists
|
||||||
|
if [ ! -f "$APK_PATH" ]; then
|
||||||
|
echo "❌ APK not found at: $APK_PATH"
|
||||||
|
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Installing APK: $APK_PATH"
|
||||||
|
adb install -r "$APK_PATH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
echo "🚀 Launch the app on your device"
|
||||||
56
scripts/find-req-implementations.sh
Executable file
@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Find all files implementing a specific requirement
|
||||||
|
#
|
||||||
|
# Usage: ./find-req-implementations.sh UR-004
|
||||||
|
#
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: $0 <REQUIREMENT_ID>"
|
||||||
|
echo "Example: $0 UR-004"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REQ_ID=$1
|
||||||
|
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " Implementations of $REQ_ID"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Full implementations
|
||||||
|
echo "Full Implementations:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -rn "@req: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||||
|
grep -v "@req-partial" | \
|
||||||
|
grep -v "@req-planned" | \
|
||||||
|
sed 's/src-tauri\/src\///' | \
|
||||||
|
sed 's/src\///' || echo " (none)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Partial implementations
|
||||||
|
echo "Partial Implementations:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -rn "@req-partial: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||||
|
sed 's/src-tauri\/src\///' | \
|
||||||
|
sed 's/src\///' || echo " (none)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Planned
|
||||||
|
echo "Planned Implementations:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -rn "@req-planned: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||||
|
sed 's/src-tauri\/src\///' | \
|
||||||
|
sed 's/src\///' || echo " (none)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
echo "Test Cases:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -rn "@req-test: $REQ_ID" src-tauri/ 2>/dev/null | \
|
||||||
|
sed 's/src-tauri\/src\///' || echo " (none)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
38
scripts/generate-traceability-matrix.sh
Executable file
@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Generate traceability matrix in Markdown format
|
||||||
|
#
|
||||||
|
|
||||||
|
echo "# Requirements Traceability Matrix"
|
||||||
|
echo ""
|
||||||
|
echo "**Generated**: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo ""
|
||||||
|
echo "| Requirement | Files Implementing | Status | Notes |"
|
||||||
|
echo "|-------------|--------------------|--------|-------|"
|
||||||
|
|
||||||
|
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" README.md | sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | sort -u)
|
||||||
|
|
||||||
|
for req in $requirements; do
|
||||||
|
files=$(grep -rl "@req: $req" src-tauri/ src/ 2>/dev/null | \
|
||||||
|
sed 's|src-tauri/src/||; s|src/||' | \
|
||||||
|
paste -sd, -)
|
||||||
|
|
||||||
|
partial_files=$(grep -rl "@req-partial: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
||||||
|
planned=$(grep -rl "@req-planned: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
if [ -n "$files" ]; then
|
||||||
|
status="✅ Done"
|
||||||
|
notes=""
|
||||||
|
elif [ "$partial_files" -gt 0 ]; then
|
||||||
|
status="🔶 Partial"
|
||||||
|
notes="Platform-specific"
|
||||||
|
elif [ "$planned" -gt 0 ]; then
|
||||||
|
status="📋 Planned"
|
||||||
|
notes="Not implemented"
|
||||||
|
else
|
||||||
|
status="❌ Missing"
|
||||||
|
notes="No implementation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "| $req | ${files:-N/A} | $status | $notes |"
|
||||||
|
done
|
||||||
13
scripts/logcat.sh
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# View Android logcat output filtered for the app
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_PACKAGE="com.jellytau.app"
|
||||||
|
|
||||||
|
echo "📱 Showing logcat for $APP_PACKAGE"
|
||||||
|
echo "Press Ctrl+C to stop"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Filter logcat for the app's package name
|
||||||
|
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
|
||||||
34
scripts/sync-android-sources.sh
Executable file
@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sync Android source files from src-tauri/android to src-tauri/gen/android
|
||||||
|
# This ensures the generated build directory has the latest source files
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/main/java/com/dtourolle/jellytau"
|
||||||
|
TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/java/com/dtourolle/jellytau"
|
||||||
|
|
||||||
|
echo "Syncing Android sources..."
|
||||||
|
echo " From: $SOURCE_DIR"
|
||||||
|
echo " To: $TARGET_DIR"
|
||||||
|
|
||||||
|
# Create target directory if it doesn't exist
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
|
||||||
|
# Remove old copies of player and security directories
|
||||||
|
rm -rf "$TARGET_DIR/player" "$TARGET_DIR/security"
|
||||||
|
|
||||||
|
# Copy the directories
|
||||||
|
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
|
||||||
|
cp -r "$SOURCE_DIR/security" "$TARGET_DIR/"
|
||||||
|
|
||||||
|
# Copy individual Kotlin files (like VideoOverlayManager.kt)
|
||||||
|
for kt_file in "$SOURCE_DIR"/*.kt; do
|
||||||
|
if [ -f "$kt_file" ]; then
|
||||||
|
cp "$kt_file" "$TARGET_DIR/"
|
||||||
|
echo " Copied: $(basename "$kt_file")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✓ Android sources synced successfully"
|
||||||
19
scripts/test-all.sh
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run all tests (frontend and backend)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧪 Running all tests..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "📦 Running frontend tests..."
|
||||||
|
bun test
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🦀 Running Rust tests..."
|
||||||
|
cd src-tauri
|
||||||
|
cargo test
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ All tests passed!"
|
||||||
7
scripts/test-frontend.sh
Executable file
@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run frontend tests only
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "📦 Running frontend tests..."
|
||||||
|
bun test "$@"
|
||||||
9
scripts/test-rust.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run Rust tests only
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🦀 Running Rust tests..."
|
||||||
|
cd src-tauri
|
||||||
|
cargo test "$@"
|
||||||
|
cd ..
|
||||||
38
src-tauri/.cargo/config.toml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
[target.aarch64-linux-android]
|
||||||
|
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
|
||||||
|
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||||
|
|
||||||
|
[target.armv7-linux-androideabi]
|
||||||
|
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
|
||||||
|
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||||
|
|
||||||
|
[target.i686-linux-android]
|
||||||
|
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
|
||||||
|
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||||
|
|
||||||
|
[target.x86_64-linux-android]
|
||||||
|
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
|
||||||
|
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
# Point to the NDK for the cc crate and other build scripts
|
||||||
|
ANDROID_NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
|
||||||
|
NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
|
||||||
|
|
||||||
|
# Set CC/CXX for each Android target (cc crate looks for these)
|
||||||
|
CC_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
|
||||||
|
CXX_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang++"
|
||||||
|
AR_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||||
|
|
||||||
|
CC_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
|
||||||
|
CXX_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang++"
|
||||||
|
AR_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||||
|
|
||||||
|
CC_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
|
||||||
|
CXX_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang++"
|
||||||
|
AR_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||||
|
|
||||||
|
CC_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
|
||||||
|
CXX_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang++"
|
||||||
|
AR_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
||||||
|
|
||||||
24
src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# Includes Android projects, schemas, and all other generated files
|
||||||
|
/gen/
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ipa
|
||||||
|
|
||||||
|
# Android/Gradle (if not using gen/)
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
**/android/**/build/
|
||||||
|
**/android/.gradle/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
5987
src-tauri/Cargo.lock
generated
Normal file
63
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
[package]
|
||||||
|
name = "jellytau"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "jellytau_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
rand = "0.8"
|
||||||
|
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# SQLite for offline storage
|
||||||
|
tokio-rusqlite = "0.6"
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
directories = "5"
|
||||||
|
|
||||||
|
# Secure credential storage (system keyring with encrypted file fallback)
|
||||||
|
keyring = "3"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
getrandom = "0.2"
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
|
||||||
|
# Linux-specific dependencies
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
hostname = "0.4"
|
||||||
|
libc = "0.2"
|
||||||
|
# Use latest git version for better MPV version compatibility
|
||||||
|
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
|
||||||
|
|
||||||
|
# JNI for Android ExoPlayer integration
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = "0.21"
|
||||||
|
ndk-context = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.24.0"
|
||||||
|
|
||||||
51
src-tauri/android/README_ANDROID_BUILD.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# ⚠️ IMPORTANT: Android Build File Locations
|
||||||
|
|
||||||
|
## Critical Information for Future Development
|
||||||
|
|
||||||
|
**DO NOT EDIT FILES IN `src-tauri/gen/android/` DIRECTLY!**
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
This project has **TWO** sets of Android source files:
|
||||||
|
|
||||||
|
1. **`src-tauri/android/`** - **SOURCE FILES** (edit these!)
|
||||||
|
- This is the template directory
|
||||||
|
- Changes here need to be copied to the generated directory
|
||||||
|
|
||||||
|
2. **`src-tauri/gen/android/`** - **GENERATED BUILD DIRECTORY** (do not edit directly!)
|
||||||
|
- This is where Gradle actually builds the APK
|
||||||
|
- Files here may be overwritten during builds
|
||||||
|
|
||||||
|
### How to Make Changes to Android Code
|
||||||
|
|
||||||
|
When you need to modify Android/Kotlin files:
|
||||||
|
|
||||||
|
1. **Edit the files in `src-tauri/android/src/main/java/`**
|
||||||
|
2. **Build using the provided script (which auto-syncs files)**
|
||||||
|
```bash
|
||||||
|
./scripts/build-android.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The build script automatically runs `./scripts/sync-android-sources.sh` which copies:
|
||||||
|
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/` → generated directory
|
||||||
|
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/security/` → generated directory
|
||||||
|
|
||||||
|
3. **Manual sync (if needed)**
|
||||||
|
```bash
|
||||||
|
./scripts/sync-android-sources.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Matters
|
||||||
|
|
||||||
|
- If you only edit `src-tauri/gen/android/`, your changes will be lost
|
||||||
|
- If you only edit `src-tauri/android/`, your changes won't be in the build
|
||||||
|
- **You must edit both** (or edit source and copy to generated)
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
Player-related Kotlin files:
|
||||||
|
- `player/JellyTauPlayer.kt` - Main player implementation
|
||||||
|
- `player/JellyTauPlaybackService.kt` - MediaSession service for lockscreen controls
|
||||||
|
- `security/SecureStorage.kt` - Android Keystore integration for secure credential storage
|
||||||
|
|
||||||
|
Always check BOTH locations exist and match after making changes!
|
||||||
@ -0,0 +1,285 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JellyTau media player wrapper using ExoPlayer (Media3).
|
||||||
|
*
|
||||||
|
* This class is designed to be called from Rust via JNI.
|
||||||
|
* All player operations are marshalled to the main thread.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class JellyTauPlayer(context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Position update interval in milliseconds */
|
||||||
|
private const val POSITION_UPDATE_INTERVAL_MS = 250L
|
||||||
|
|
||||||
|
/** Singleton instance for JNI access */
|
||||||
|
@Volatile
|
||||||
|
private var instance: JellyTauPlayer? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Load the native library for JNI callbacks
|
||||||
|
System.loadLibrary("jellytau_lib")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the player singleton.
|
||||||
|
* Called from Rust via JNI during Android startup.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = JellyTauPlayer(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton instance.
|
||||||
|
* @throws IllegalStateException if not initialized
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(): JellyTauPlayer {
|
||||||
|
return instance ?: throw IllegalStateException("JellyTauPlayer not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val exoPlayer: ExoPlayer
|
||||||
|
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
private var positionUpdateJob: Job? = null
|
||||||
|
|
||||||
|
/** Current media ID being played */
|
||||||
|
private var currentMediaId: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Create ExoPlayer on main thread
|
||||||
|
exoPlayer = ExoPlayer.Builder(context).build()
|
||||||
|
|
||||||
|
// Set up player listener
|
||||||
|
exoPlayer.addListener(object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
when (playbackState) {
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
// Media loaded and ready
|
||||||
|
val duration = exoPlayer.duration / 1000.0
|
||||||
|
nativeOnMediaLoaded(duration)
|
||||||
|
|
||||||
|
val state = if (exoPlayer.isPlaying) "playing" else "paused"
|
||||||
|
nativeOnStateChanged(state, currentMediaId)
|
||||||
|
}
|
||||||
|
Player.STATE_ENDED -> {
|
||||||
|
// Playback completed
|
||||||
|
stopPositionUpdates()
|
||||||
|
nativeOnPlaybackEnded()
|
||||||
|
}
|
||||||
|
Player.STATE_BUFFERING -> {
|
||||||
|
nativeOnBuffering(0)
|
||||||
|
}
|
||||||
|
Player.STATE_IDLE -> {
|
||||||
|
// Player is idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
val state = if (isPlaying) "playing" else "paused"
|
||||||
|
nativeOnStateChanged(state, currentMediaId)
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
startPositionUpdates()
|
||||||
|
} else {
|
||||||
|
stopPositionUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
val message = error.message ?: "Unknown playback error"
|
||||||
|
val recoverable = error.errorCode != PlaybackException.ERROR_CODE_UNSPECIFIED
|
||||||
|
nativeOnError(message, recoverable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load media from a URL.
|
||||||
|
* @param url The media URL to load
|
||||||
|
* @param mediaId The unique ID for this media item
|
||||||
|
*/
|
||||||
|
fun load(url: String, mediaId: String) {
|
||||||
|
mainHandler.post {
|
||||||
|
currentMediaId = mediaId
|
||||||
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
|
exoPlayer.prepare()
|
||||||
|
exoPlayer.playWhenReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start or resume playback.
|
||||||
|
*/
|
||||||
|
fun play() {
|
||||||
|
mainHandler.post {
|
||||||
|
exoPlayer.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause playback.
|
||||||
|
*/
|
||||||
|
fun pause() {
|
||||||
|
mainHandler.post {
|
||||||
|
exoPlayer.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback and release media.
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
mainHandler.post {
|
||||||
|
stopPositionUpdates()
|
||||||
|
exoPlayer.stop()
|
||||||
|
exoPlayer.clearMediaItems()
|
||||||
|
currentMediaId = null
|
||||||
|
nativeOnStateChanged("idle", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to a position.
|
||||||
|
* @param positionSeconds Position in seconds
|
||||||
|
*/
|
||||||
|
fun seek(positionSeconds: Double) {
|
||||||
|
mainHandler.post {
|
||||||
|
val positionMs = (positionSeconds * 1000).toLong()
|
||||||
|
exoPlayer.seekTo(positionMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the volume.
|
||||||
|
* @param volume Volume level from 0.0 to 1.0
|
||||||
|
*/
|
||||||
|
fun setVolume(volume: Float) {
|
||||||
|
mainHandler.post {
|
||||||
|
exoPlayer.volume = volume.coerceIn(0f, 1f)
|
||||||
|
nativeOnVolumeChanged(exoPlayer.volume, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current playback position in seconds.
|
||||||
|
*/
|
||||||
|
fun getPosition(): Double {
|
||||||
|
return exoPlayer.currentPosition / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total duration in seconds.
|
||||||
|
*/
|
||||||
|
fun getDuration(): Double {
|
||||||
|
val duration = exoPlayer.duration
|
||||||
|
return if (duration > 0) duration / 1000.0 else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current volume.
|
||||||
|
*/
|
||||||
|
fun getVolume(): Float {
|
||||||
|
return exoPlayer.volume
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if media is currently loaded.
|
||||||
|
*/
|
||||||
|
fun isLoaded(): Boolean {
|
||||||
|
return exoPlayer.playbackState == Player.STATE_READY ||
|
||||||
|
exoPlayer.playbackState == Player.STATE_BUFFERING
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release player resources.
|
||||||
|
* Call when the app is closing.
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
mainHandler.post {
|
||||||
|
stopPositionUpdates()
|
||||||
|
coroutineScope.cancel()
|
||||||
|
exoPlayer.release()
|
||||||
|
instance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPositionUpdates() {
|
||||||
|
positionUpdateJob?.cancel()
|
||||||
|
positionUpdateJob = coroutineScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
if (exoPlayer.isPlaying) {
|
||||||
|
val position = exoPlayer.currentPosition / 1000.0
|
||||||
|
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||||
|
nativeOnPositionUpdate(position, duration)
|
||||||
|
}
|
||||||
|
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPositionUpdates() {
|
||||||
|
positionUpdateJob?.cancel()
|
||||||
|
positionUpdateJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native methods to call back to Rust via JNI
|
||||||
|
// These will be implemented in the Rust android module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when position updates during playback.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when player state changes.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when media has finished loading.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnMediaLoaded(duration: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when playback reaches the end.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnPlaybackEnded()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when buffering state changes.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnBuffering(percent: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a playback error occurs.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnError(message: String, recoverable: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when volume changes.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnVolumeChanged(volume: Float, muted: Boolean)
|
||||||
|
}
|
||||||
39
src-tauri/android/build.gradle.kts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.dtourolle.jellytau.player"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-common:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-session:1.5.1")
|
||||||
|
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||||
|
}
|
||||||
5
src-tauri/android/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Enable hardware acceleration for video playback performance -->
|
||||||
|
<application android:hardwareAccelerated="true" />
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,226 @@
|
|||||||
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var configAttempts = 0
|
||||||
|
private val maxConfigAttempts = 10
|
||||||
|
private var audioFocusRequest: AudioFocusRequest? = null
|
||||||
|
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Configure WebView for media playback after Tauri initialization
|
||||||
|
handler.postDelayed({
|
||||||
|
configureWebViewForMedia()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
configureWebViewForMedia()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureWebViewForMedia() {
|
||||||
|
try {
|
||||||
|
val webView = findWebView(window.decorView)
|
||||||
|
|
||||||
|
if (webView == null) {
|
||||||
|
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
|
||||||
|
if (configAttempts < maxConfigAttempts) {
|
||||||
|
configAttempts++
|
||||||
|
handler.postDelayed({
|
||||||
|
configureWebViewForMedia()
|
||||||
|
}, 200)
|
||||||
|
} else {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to find WebView after $maxConfigAttempts attempts")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
|
|
||||||
|
// Add JavaScript interface for audio focus control
|
||||||
|
webView.addJavascriptInterface(object : Any() {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun requestAudioFocus() {
|
||||||
|
handler.post { this@MainActivity.requestAudioFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun abandonAudioFocus() {
|
||||||
|
handler.post { this@MainActivity.abandonAudioFocus() }
|
||||||
|
}
|
||||||
|
}, "AndroidAudioFocus")
|
||||||
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
||||||
|
|
||||||
|
// Set WebChromeClient to handle video playback and audio focus
|
||||||
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||||
|
super.onShowCustomView(view, callback)
|
||||||
|
android.util.Log.d("MainActivity", "Video entered fullscreen")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHideCustomView() {
|
||||||
|
super.onHideCustomView()
|
||||||
|
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
||||||
|
|
||||||
|
webView.settings.apply {
|
||||||
|
// CRITICAL: Enable media playback without user gesture requirement
|
||||||
|
mediaPlaybackRequiresUserGesture = false
|
||||||
|
android.util.Log.d("MainActivity", "Set mediaPlaybackRequiresUserGesture = false")
|
||||||
|
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
allowFileAccess = true
|
||||||
|
allowContentAccess = true
|
||||||
|
setRenderPriority(WebSettings.RenderPriority.HIGH)
|
||||||
|
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
|
||||||
|
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute JavaScript to ensure any video elements are unmuted and request audio focus
|
||||||
|
webView.post {
|
||||||
|
webView.evaluateJavascript("""
|
||||||
|
(function() {
|
||||||
|
console.log('[Android] Ensuring video elements are unmuted');
|
||||||
|
const videos = document.getElementsByTagName('video');
|
||||||
|
for (let video of videos) {
|
||||||
|
video.muted = false;
|
||||||
|
video.volume = 1.0;
|
||||||
|
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
||||||
|
|
||||||
|
// Add event listeners to manage audio focus
|
||||||
|
video.addEventListener('play', function() {
|
||||||
|
console.log('[Android] Video play event - requesting audio focus');
|
||||||
|
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||||
|
AndroidAudioFocus.requestAudioFocus();
|
||||||
|
}
|
||||||
|
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('pause', function() {
|
||||||
|
console.log('[Android] Video pause event - abandoning audio focus');
|
||||||
|
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||||
|
AndroidAudioFocus.abandonAudioFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('ended', function() {
|
||||||
|
console.log('[Android] Video ended event - abandoning audio focus');
|
||||||
|
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||||
|
AndroidAudioFocus.abandonAudioFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('volumechange', function() {
|
||||||
|
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor for new video elements
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const videos = document.getElementsByTagName('video');
|
||||||
|
for (let video of videos) {
|
||||||
|
if (video.muted) {
|
||||||
|
video.muted = false;
|
||||||
|
video.volume = 1.0;
|
||||||
|
console.log('[Android] New video found and unmuted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
console.log('[Android] Video unmute observer installed');
|
||||||
|
})();
|
||||||
|
""".trimIndent(), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findWebView(view: android.view.View): WebView? {
|
||||||
|
if (view is WebView) {
|
||||||
|
android.util.Log.d("MainActivity", "Found WebView!")
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view is android.view.ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
val child = view.getChildAt(i)
|
||||||
|
val webView = findWebView(child)
|
||||||
|
if (webView != null) {
|
||||||
|
return webView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestAudioFocus() {
|
||||||
|
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
.setAudioAttributes(audioAttributes)
|
||||||
|
.setAcceptsDelayedFocusGain(true)
|
||||||
|
.setOnAudioFocusChangeListener { focusChange ->
|
||||||
|
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||||
|
android.util.Log.d("MainActivity", "Audio focus request result: $result")
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val result = audioManager.requestAudioFocus(
|
||||||
|
{ focusChange ->
|
||||||
|
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||||
|
},
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN
|
||||||
|
)
|
||||||
|
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonAudioFocus() {
|
||||||
|
android.util.Log.d("MainActivity", "Abandoning audio focus")
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
audioFocusRequest?.let {
|
||||||
|
audioManager.abandonAudioFocusRequest(it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.abandonAudioFocus { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.media.MediaCodecList
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects hardware codec capabilities using MediaCodecList.
|
||||||
|
*
|
||||||
|
* This class queries the device's media codec capabilities and reports
|
||||||
|
* them to the Rust backend via JNI for accurate DeviceProfile generation.
|
||||||
|
*/
|
||||||
|
object CodecDetector {
|
||||||
|
private const val TAG = "CodecDetector"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class to hold detected codec capabilities.
|
||||||
|
*/
|
||||||
|
data class CodecCapabilities(
|
||||||
|
val videoCodecs: List<String>,
|
||||||
|
val audioCodecs: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect all hardware decoders available on this device.
|
||||||
|
*
|
||||||
|
* Uses Android's MediaCodecList API to query supported MIME types
|
||||||
|
* and maps them to Jellyfin codec names.
|
||||||
|
*
|
||||||
|
* @return CodecCapabilities containing lists of supported video and audio codecs
|
||||||
|
*/
|
||||||
|
fun detectHardwareCodecs(): CodecCapabilities {
|
||||||
|
val videoCodecs = mutableSetOf<String>()
|
||||||
|
val audioCodecs = mutableSetOf<String>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all codec infos (including both hardware and software codecs)
|
||||||
|
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||||
|
|
||||||
|
for (codecInfo in codecList.codecInfos) {
|
||||||
|
// Only interested in decoders (not encoders)
|
||||||
|
if (codecInfo.isEncoder) continue
|
||||||
|
|
||||||
|
// Check if it's a hardware codec
|
||||||
|
val isHardware = !codecInfo.isSoftwareOnly
|
||||||
|
|
||||||
|
for (type in codecInfo.supportedTypes) {
|
||||||
|
when {
|
||||||
|
type.startsWith("video/") -> {
|
||||||
|
val codec = mapMimeTypeToCodecName(type, isVideo = true)
|
||||||
|
if (codec != null) {
|
||||||
|
videoCodecs.add(codec)
|
||||||
|
Log.d(TAG, "Video codec: $codec (MIME: $type, Hardware: $isHardware)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type.startsWith("audio/") -> {
|
||||||
|
val codec = mapMimeTypeToCodecName(type, isVideo = false)
|
||||||
|
if (codec != null) {
|
||||||
|
audioCodecs.add(codec)
|
||||||
|
Log.d(TAG, "Audio codec: $codec (MIME: $type, Hardware: $isHardware)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
|
||||||
|
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error detecting codecs", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CodecCapabilities(
|
||||||
|
videoCodecs = videoCodecs.sorted(),
|
||||||
|
audioCodecs = audioCodecs.sorted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Android MIME types to Jellyfin codec names.
|
||||||
|
*
|
||||||
|
* Based on Jellyfin's codec naming conventions and Android's
|
||||||
|
* supported MIME type constants.
|
||||||
|
*
|
||||||
|
* @param mimeType Android MIME type (e.g., "video/avc", "audio/mp4a-latm")
|
||||||
|
* @param isVideo Whether this is a video codec
|
||||||
|
* @return Jellyfin codec name or null if unknown
|
||||||
|
*/
|
||||||
|
private fun mapMimeTypeToCodecName(mimeType: String, isVideo: Boolean): String? {
|
||||||
|
return when (mimeType) {
|
||||||
|
// Video codecs
|
||||||
|
"video/avc" -> "h264"
|
||||||
|
"video/hevc" -> "hevc"
|
||||||
|
"video/x-vnd.on2.vp8" -> "vp8"
|
||||||
|
"video/x-vnd.on2.vp9" -> "vp9"
|
||||||
|
"video/av01" -> "av1"
|
||||||
|
"video/mp4v-es" -> "mpeg4"
|
||||||
|
"video/3gpp" -> "h263"
|
||||||
|
"video/mpeg2" -> "mpeg2video"
|
||||||
|
"video/divx" -> "divx"
|
||||||
|
"video/xvid" -> "xvid"
|
||||||
|
"video/x-ms-wmv" -> "wmv"
|
||||||
|
"video/vc1" -> "vc1"
|
||||||
|
|
||||||
|
// Audio codecs
|
||||||
|
"audio/mp4a-latm" -> "aac"
|
||||||
|
"audio/mpeg" -> "mp3"
|
||||||
|
"audio/mpeg-L1" -> "mp1"
|
||||||
|
"audio/mpeg-L2" -> "mp2"
|
||||||
|
"audio/opus" -> "opus"
|
||||||
|
"audio/vorbis" -> "vorbis"
|
||||||
|
"audio/flac" -> "flac"
|
||||||
|
"audio/alac" -> "alac"
|
||||||
|
"audio/ac3" -> "ac3"
|
||||||
|
"audio/eac3" -> "eac3"
|
||||||
|
"audio/eac3-joc" -> "eac3"
|
||||||
|
"audio/dts" -> "dts"
|
||||||
|
"audio/vnd.dts.hd" -> "dts"
|
||||||
|
"audio/x-ms-wma" -> "wma"
|
||||||
|
"audio/amr-nb" -> "amrnb"
|
||||||
|
"audio/amr-wb" -> "amrwb"
|
||||||
|
"audio/3gpp" -> "amrnb"
|
||||||
|
"audio/raw" -> "pcm"
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "Unknown MIME type: $mimeType")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,527 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.media.VolumeProviderCompat
|
||||||
|
import androidx.media3.common.ForwardingPlayer
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import com.google.common.util.concurrent.Futures
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MediaSessionService for lockscreen controls and media notifications.
|
||||||
|
*
|
||||||
|
* This service creates a MediaSession that integrates with the system's
|
||||||
|
* media controls (lockscreen, notification shade, Bluetooth devices).
|
||||||
|
*
|
||||||
|
* Media commands are routed back to Rust via JNI to ensure proper
|
||||||
|
* queue management for next/previous track operations.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class JellyTauPlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
|
private var mediaSession: MediaSession? = null
|
||||||
|
private var mediaSessionCompat: MediaSessionCompat? = null
|
||||||
|
private var wrappedPlayer: androidx.media3.common.ForwardingPlayer? = null
|
||||||
|
private var volumeProvider: VolumeProviderCompat? = null
|
||||||
|
private var isRemoteVolumeEnabled = false
|
||||||
|
private var remoteVolumeLevel = 50 // 0-100
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val NOTIFICATION_CHANNEL_ID = "playback_channel"
|
||||||
|
private const val NOTIFICATION_CHANNEL_NAME = "Playback"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: JellyTauPlaybackService? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the service instance if running.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(): JellyTauPlaybackService? = instance
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Ensure native library is loaded for JNI callbacks
|
||||||
|
System.loadLibrary("jellytau_lib")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
|
||||||
|
// Create notification channel for Android O+
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
NOTIFICATION_CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Media playback controls"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if JellyTauPlayer is initialized
|
||||||
|
if (!JellyTauPlayer.isInitialized()) {
|
||||||
|
android.util.Log.w("JellyTauPlaybackService", "JellyTauPlayer not initialized, initializing now")
|
||||||
|
// Initialize the player with application context
|
||||||
|
JellyTauPlayer.initialize(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the existing JellyTauPlayer instance with its ExoPlayer
|
||||||
|
val jellyTauPlayer = JellyTauPlayer.getInstance()
|
||||||
|
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
||||||
|
|
||||||
|
// Wrap the ExoPlayer to intercept commands
|
||||||
|
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
||||||
|
override fun play() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.play()
|
||||||
|
// Then notify Rust for state management
|
||||||
|
nativeOnMediaCommand("play")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.pause()
|
||||||
|
// Then notify Rust for state management
|
||||||
|
nativeOnMediaCommand("pause")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToNext() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.seekToNext()
|
||||||
|
// Then notify Rust for queue management
|
||||||
|
nativeOnMediaCommand("next")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToPrevious() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.seekToPrevious()
|
||||||
|
// Then notify Rust for queue management
|
||||||
|
nativeOnMediaCommand("previous")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekTo(positionMs: Long) {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.seekTo(positionMs)
|
||||||
|
// Then notify Rust of seek
|
||||||
|
val positionSeconds = positionMs / 1000.0
|
||||||
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.stop()
|
||||||
|
// Then notify Rust for state management
|
||||||
|
nativeOnMediaCommand("stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create MediaSession with the wrapped player and callback for command handling
|
||||||
|
mediaSession = MediaSession.Builder(this, wrappedPlayer!!)
|
||||||
|
.setCallback(object : MediaSession.Callback {
|
||||||
|
override fun onSetMediaItems(
|
||||||
|
mediaSession: MediaSession,
|
||||||
|
controller: MediaSession.ControllerInfo,
|
||||||
|
mediaItems: MutableList<androidx.media3.common.MediaItem>,
|
||||||
|
startIndex: Int,
|
||||||
|
startPositionMs: Long
|
||||||
|
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||||
|
return Futures.immediateFuture(
|
||||||
|
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Create MediaSessionCompat for volume control and lock screen button handling
|
||||||
|
// We need this alongside Media3's MediaSession because MediaSessionCompat provides
|
||||||
|
// VolumeProviderCompat support for remote volume control (routing hardware button presses)
|
||||||
|
mediaSessionCompat = MediaSessionCompat(this, "JellyTauMediaSession").apply {
|
||||||
|
setFlags(
|
||||||
|
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
|
||||||
|
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
|
||||||
|
)
|
||||||
|
isActive = true
|
||||||
|
|
||||||
|
// Set callback to handle lock screen button presses
|
||||||
|
setCallback(object : MediaSessionCompat.Callback() {
|
||||||
|
override fun onPlay() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
||||||
|
wrappedPlayer?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
||||||
|
wrappedPlayer?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSkipToNext() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
||||||
|
wrappedPlayer?.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSkipToPrevious() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
||||||
|
wrappedPlayer?.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||||
|
wrappedPlayer?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSeekTo(position: Long) {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||||
|
wrappedPlayer?.seekTo(position)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
// Start as foreground service immediately to avoid crash
|
||||||
|
// Media3 will replace this with its own notification
|
||||||
|
val notification = createBasicNotification()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
return super.onStartCommand(intent, flags, startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBasicNotification(): Notification {
|
||||||
|
// Create a media-style notification with lockscreen controls
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setContentTitle("JellyTau")
|
||||||
|
.setContentText("Playing")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setStyle(
|
||||||
|
androidx.media.app.NotificationCompat.MediaStyle()
|
||||||
|
.setMediaSession(mediaSessionCompat?.sessionToken)
|
||||||
|
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_previous,
|
||||||
|
"Previous",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_pause,
|
||||||
|
"Pause",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_PAUSE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_next,
|
||||||
|
"Next",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the MediaSession metadata and playback state.
|
||||||
|
* This updates both the MediaSession and the notification.
|
||||||
|
*/
|
||||||
|
fun updateMediaMetadata(
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String?,
|
||||||
|
duration: Long,
|
||||||
|
position: Long,
|
||||||
|
isPlaying: Boolean
|
||||||
|
) {
|
||||||
|
val session = mediaSessionCompat ?: return
|
||||||
|
|
||||||
|
// Update MediaSession metadata
|
||||||
|
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
||||||
|
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||||
|
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
||||||
|
.putLong(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DURATION, duration)
|
||||||
|
|
||||||
|
album?.let {
|
||||||
|
metadataBuilder.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ALBUM, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.setMetadata(metadataBuilder.build())
|
||||||
|
|
||||||
|
// Update MediaSession playback state
|
||||||
|
val stateBuilder = PlaybackStateCompat.Builder()
|
||||||
|
.setActions(
|
||||||
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
|
PlaybackStateCompat.ACTION_STOP or
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||||
|
PlaybackStateCompat.ACTION_SEEK_TO
|
||||||
|
)
|
||||||
|
.setState(
|
||||||
|
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||||
|
position,
|
||||||
|
1.0f
|
||||||
|
)
|
||||||
|
|
||||||
|
session.setPlaybackState(stateBuilder.build())
|
||||||
|
|
||||||
|
// Update the notification
|
||||||
|
updateNotification(title, artist, isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the notification with current media metadata and playback state.
|
||||||
|
* This should be called whenever metadata or playback state changes.
|
||||||
|
*/
|
||||||
|
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(artist)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setStyle(
|
||||||
|
androidx.media.app.NotificationCompat.MediaStyle()
|
||||||
|
.setMediaSession(mediaSessionCompat?.sessionToken)
|
||||||
|
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_previous,
|
||||||
|
"Previous",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
|
||||||
|
if (isPlaying) "Pause" else "Play",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
if (isPlaying) PlaybackStateCompat.ACTION_PAUSE else PlaybackStateCompat.ACTION_PLAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_next,
|
||||||
|
"Next",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(isPlaying)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||||
|
return mediaSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable remote volume control for remote playback (e.g., casting to Jellyfin session).
|
||||||
|
* Volume button presses will be sent to Rust for forwarding to the remote session.
|
||||||
|
*
|
||||||
|
* Uses MediaSessionCompat with VolumeProviderCompat to intercept hardware volume buttons.
|
||||||
|
*
|
||||||
|
* @param initialVolume Initial volume level (0-100)
|
||||||
|
*/
|
||||||
|
fun enableRemoteVolume(initialVolume: Int) {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Enabling remote volume control (volume=$initialVolume)")
|
||||||
|
isRemoteVolumeEnabled = true
|
||||||
|
remoteVolumeLevel = initialVolume.coerceIn(0, 100)
|
||||||
|
|
||||||
|
val session = mediaSessionCompat ?: run {
|
||||||
|
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a VolumeProvider for remote volume control
|
||||||
|
volumeProvider = object : VolumeProviderCompat(
|
||||||
|
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, // Control type: absolute volume
|
||||||
|
100, // Max volume (0-100)
|
||||||
|
remoteVolumeLevel // Initial volume
|
||||||
|
) {
|
||||||
|
override fun onSetVolumeTo(volume: Int) {
|
||||||
|
if (!isRemoteVolumeEnabled) return
|
||||||
|
|
||||||
|
remoteVolumeLevel = volume.coerceIn(0, 100)
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume set to $remoteVolumeLevel")
|
||||||
|
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAdjustVolume(direction: Int) {
|
||||||
|
if (!isRemoteVolumeEnabled) return
|
||||||
|
|
||||||
|
when (direction) {
|
||||||
|
android.media.AudioManager.ADJUST_RAISE -> {
|
||||||
|
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume up to $remoteVolumeLevel")
|
||||||
|
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
|
||||||
|
// Update the current volume so slider reflects the change
|
||||||
|
currentVolume = remoteVolumeLevel
|
||||||
|
}
|
||||||
|
android.media.AudioManager.ADJUST_LOWER -> {
|
||||||
|
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume down to $remoteVolumeLevel")
|
||||||
|
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
|
||||||
|
// Update the current volume so slider reflects the change
|
||||||
|
currentVolume = remoteVolumeLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the volume provider on the media session to route hardware volume buttons
|
||||||
|
session.setPlaybackToRemote(volumeProvider!!)
|
||||||
|
|
||||||
|
// Set playback state to make Android show the volume UI
|
||||||
|
// This tells Android that this session is actively controlling media playback
|
||||||
|
val playbackState = PlaybackStateCompat.Builder()
|
||||||
|
.setState(
|
||||||
|
PlaybackStateCompat.STATE_PLAYING,
|
||||||
|
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
|
||||||
|
1.0f
|
||||||
|
)
|
||||||
|
.setActions(
|
||||||
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
session.setPlaybackState(playbackState)
|
||||||
|
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume control enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable remote volume control and return to local volume control.
|
||||||
|
* Volume buttons will control system media volume (ExoPlayer volume).
|
||||||
|
*/
|
||||||
|
fun disableRemoteVolume() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Disabling remote volume control")
|
||||||
|
isRemoteVolumeEnabled = false
|
||||||
|
|
||||||
|
val session = mediaSessionCompat ?: run {
|
||||||
|
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch back to local audio stream (device volume)
|
||||||
|
session.setPlaybackToLocal(android.media.AudioManager.STREAM_MUSIC)
|
||||||
|
|
||||||
|
// Clear the playback state
|
||||||
|
val idleState = PlaybackStateCompat.Builder()
|
||||||
|
.setState(
|
||||||
|
PlaybackStateCompat.STATE_NONE,
|
||||||
|
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
|
||||||
|
0.0f
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
session.setPlaybackState(idleState)
|
||||||
|
|
||||||
|
// Clear the volume provider
|
||||||
|
volumeProvider = null
|
||||||
|
|
||||||
|
// Reset volume level to default
|
||||||
|
remoteVolumeLevel = 50
|
||||||
|
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume control disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the remote volume level.
|
||||||
|
* Call this when volume changes on the remote session to sync the local state.
|
||||||
|
*
|
||||||
|
* @param volume Volume level (0-100)
|
||||||
|
*/
|
||||||
|
fun updateRemoteVolume(volume: Int) {
|
||||||
|
remoteVolumeLevel = volume.coerceIn(0, 100)
|
||||||
|
// Update the volume provider's current volume so the UI slider reflects the change
|
||||||
|
volumeProvider?.currentVolume = remoteVolumeLevel
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume updated to $remoteVolumeLevel")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
mediaSession?.run {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
mediaSession = null
|
||||||
|
|
||||||
|
mediaSessionCompat?.run {
|
||||||
|
isActive = false
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
mediaSessionCompat = null
|
||||||
|
volumeProvider = null
|
||||||
|
|
||||||
|
instance = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
// Stop the service when the app is swiped away, unless audio is playing
|
||||||
|
val player = mediaSession?.player
|
||||||
|
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI callback to Rust for media commands.
|
||||||
|
* Commands: "play", "pause", "next", "previous", "stop", "seek:123.45"
|
||||||
|
*/
|
||||||
|
private external fun nativeOnMediaCommand(command: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI callback to Rust for remote volume changes.
|
||||||
|
* Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
||||||
|
* @param command The volume command
|
||||||
|
* @param volume The volume level (0-100)
|
||||||
|
*/
|
||||||
|
private external fun nativeOnRemoteVolumeChange(command: String, volume: Int)
|
||||||
|
}
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
package com.dtourolle.jellytau.security
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure storage for credentials using Android Keystore.
|
||||||
|
* Provides encrypted storage for sensitive data like API tokens.
|
||||||
|
*/
|
||||||
|
class SecureStorage private constructor(context: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SecureStorage"
|
||||||
|
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val KEY_ALIAS = "jellytau_credentials_key"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val PREFS_NAME = "jellytau_secure_prefs"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: SecureStorage? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = SecureStorage(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(): SecureStorage {
|
||||||
|
return instance ?: throw IllegalStateException("SecureStorage not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Ensure encryption key exists
|
||||||
|
if (!keyStore.containsAlias(KEY_ALIAS)) {
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateKey() {
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(
|
||||||
|
KeyProperties.KEY_ALGORITHM_AES,
|
||||||
|
KEYSTORE_PROVIDER
|
||||||
|
)
|
||||||
|
|
||||||
|
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyGenerator.init(keyGenParameterSpec)
|
||||||
|
keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSecretKey(): SecretKey {
|
||||||
|
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCredential(key: String, value: String) {
|
||||||
|
try {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
|
||||||
|
|
||||||
|
val iv = cipher.iv
|
||||||
|
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
// Store IV + encrypted data as base64
|
||||||
|
val combined = iv + encrypted
|
||||||
|
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
|
||||||
|
prefs.edit().putString(key, encoded).apply()
|
||||||
|
Log.d(TAG, "Saved credential: $key")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save credential: $key", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCredential(key: String): String? {
|
||||||
|
try {
|
||||||
|
val encoded = prefs.getString(key, null) ?: return null
|
||||||
|
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||||
|
|
||||||
|
// Extract IV (first 12 bytes for GCM)
|
||||||
|
val iv = combined.copyOfRange(0, 12)
|
||||||
|
val encrypted = combined.copyOfRange(12, combined.size)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(128, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
|
||||||
|
|
||||||
|
val decrypted = cipher.doFinal(encrypted)
|
||||||
|
return String(decrypted, Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get credential: $key", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCredential(key: String) {
|
||||||
|
prefs.edit().remove(key).apply()
|
||||||
|
Log.d(TAG, "Deleted credential: $key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// JNI-compatible methods (called from Rust)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a token (JNI-compatible version).
|
||||||
|
* @return true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
@JvmOverloads
|
||||||
|
fun saveToken(key: String, value: String): Boolean {
|
||||||
|
return try {
|
||||||
|
saveCredential(key, value)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "saveToken failed for key: $key", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a token (JNI-compatible version).
|
||||||
|
* @return token string or null if not found
|
||||||
|
*/
|
||||||
|
fun getToken(key: String): String? {
|
||||||
|
return getCredential(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a token (JNI-compatible version).
|
||||||
|
* @return true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
fun deleteToken(key: String): Boolean {
|
||||||
|
return try {
|
||||||
|
deleteCredential(key)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "deleteToken failed for key: $key", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src-tauri/android/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme -->
|
||||||
|
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Status bar color -->
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<!-- Make status bar icons dark or light based on background -->
|
||||||
|
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||||
|
<!-- Don't draw behind status bar -->
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
|
<!-- Ensure content doesn't extend into system bars -->
|
||||||
|
<item name="android:fitsSystemWindows">true</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
11
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default",
|
||||||
|
"core:path:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
409
src-tauri/src/auth/mod.rs
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
pub mod session_verifier;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::jellyfin::http_client::HttpClient;
|
||||||
|
use crate::connectivity::ConnectivityMonitor;
|
||||||
|
|
||||||
|
pub use session_verifier::SessionVerifier;
|
||||||
|
|
||||||
|
/// Server information returned from Jellyfin
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ServerInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub id: String,
|
||||||
|
/// Normalized server URL with protocol and no trailing slash
|
||||||
|
pub normalized_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server_id: String,
|
||||||
|
pub primary_image_tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication result
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AuthResult {
|
||||||
|
pub user: User,
|
||||||
|
pub access_token: String,
|
||||||
|
pub server_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active session for restoration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Session {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub server_id: String,
|
||||||
|
pub server_url: String,
|
||||||
|
pub server_name: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub verified: bool,
|
||||||
|
pub needs_reauth: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jellyfin API response types (PascalCase from server)
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct PublicSystemInfo {
|
||||||
|
server_name: String,
|
||||||
|
version: String,
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct AuthenticateByNameResponse {
|
||||||
|
user: JellyfinUser,
|
||||||
|
access_token: String,
|
||||||
|
server_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct JellyfinUser {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
server_id: String,
|
||||||
|
primary_image_tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication manager
|
||||||
|
pub struct AuthManager {
|
||||||
|
http_client: Arc<HttpClient>,
|
||||||
|
current_session: Arc<RwLock<Option<Session>>>,
|
||||||
|
connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthManager {
|
||||||
|
/// Create a new auth manager
|
||||||
|
pub fn new(http_client: HttpClient) -> Self {
|
||||||
|
Self {
|
||||||
|
http_client: Arc::new(http_client),
|
||||||
|
current_session: Arc::new(RwLock::new(None)),
|
||||||
|
connectivity_monitor: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the connectivity monitor (for marking server reachability)
|
||||||
|
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
|
||||||
|
self.connectivity_monitor = Some(monitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize and validate server URL
|
||||||
|
pub fn normalize_url(url: &str) -> String {
|
||||||
|
let mut normalized = url.trim().to_string();
|
||||||
|
|
||||||
|
// Add https:// if no protocol specified
|
||||||
|
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
|
||||||
|
normalized = format!("https://{}", normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash
|
||||||
|
if normalized.ends_with('/') {
|
||||||
|
normalized.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to server and get server info
|
||||||
|
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||||
|
let normalized_url = Self::normalize_url(server_url);
|
||||||
|
let endpoint = format!("{}/System/Info/Public", normalized_url);
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||||
|
|
||||||
|
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
|
||||||
|
Ok(info) => {
|
||||||
|
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
||||||
|
|
||||||
|
// Mark server as reachable
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_reachable().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ServerInfo {
|
||||||
|
name: info.server_name,
|
||||||
|
version: info.version,
|
||||||
|
id: info.id,
|
||||||
|
normalized_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[AuthManager] Failed to connect to server: {}", e);
|
||||||
|
|
||||||
|
// Mark server as unreachable
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_unreachable(Some(e.clone())).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate by username and password
|
||||||
|
pub async fn login(
|
||||||
|
&self,
|
||||||
|
server_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
device_id: &str,
|
||||||
|
) -> Result<AuthResult, String> {
|
||||||
|
let url = Self::normalize_url(server_url);
|
||||||
|
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||||
|
|
||||||
|
// Build auth header for login request
|
||||||
|
let auth_header = HttpClient::build_auth_header(None, device_id);
|
||||||
|
|
||||||
|
// Build request manually for custom headers
|
||||||
|
let request = self.http_client.client.post(&endpoint)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Emby-Authorization", auth_header)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"Username": username,
|
||||||
|
"Pw": password,
|
||||||
|
}))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
|
// Use retry logic
|
||||||
|
let response = self.http_client.request_with_retry(request).await
|
||||||
|
.map_err(|e| format!("Login request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_response: AuthenticateByNameResponse = response.json().await
|
||||||
|
.map_err(|e| format!("Failed to parse login response: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
|
||||||
|
|
||||||
|
// Mark server as reachable
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_reachable().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = User {
|
||||||
|
id: auth_response.user.id,
|
||||||
|
name: auth_response.user.name,
|
||||||
|
server_id: auth_response.user.server_id,
|
||||||
|
primary_image_tag: auth_response.user.primary_image_tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AuthResult {
|
||||||
|
user,
|
||||||
|
access_token: auth_response.access_token,
|
||||||
|
server_id: auth_response.server_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify current session by fetching user info
|
||||||
|
pub async fn verify_session(
|
||||||
|
&self,
|
||||||
|
server_url: &str,
|
||||||
|
user_id: &str,
|
||||||
|
access_token: &str,
|
||||||
|
device_id: &str,
|
||||||
|
) -> Result<User, String> {
|
||||||
|
let url = Self::normalize_url(server_url);
|
||||||
|
let endpoint = format!("{}/Users/{}", url, user_id);
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
||||||
|
|
||||||
|
// Build auth header
|
||||||
|
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||||
|
|
||||||
|
// Build request manually for custom headers
|
||||||
|
let request = self.http_client.client.get(&endpoint)
|
||||||
|
.header("X-Emby-Authorization", auth_header)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
|
// Use retry logic
|
||||||
|
let response = self.http_client.request_with_retry(request).await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::warn!("[AuthManager] Session verification failed: {}", e);
|
||||||
|
format!("Session verification failed: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
|
||||||
|
// Mark server as unreachable for auth errors
|
||||||
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||||
|
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(format!("HTTP {}: {}", status, error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_response: JellyfinUser = response.json().await
|
||||||
|
.map_err(|e| format!("Failed to parse user response: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
|
||||||
|
|
||||||
|
// Mark server as reachable
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_reachable().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(User {
|
||||||
|
id: user_response.id,
|
||||||
|
name: user_response.name,
|
||||||
|
server_id: user_response.server_id,
|
||||||
|
primary_image_tag: user_response.primary_image_tag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout (call Jellyfin logout endpoint)
|
||||||
|
pub async fn logout(
|
||||||
|
&self,
|
||||||
|
server_url: &str,
|
||||||
|
access_token: &str,
|
||||||
|
device_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let url = Self::normalize_url(server_url);
|
||||||
|
let endpoint = format!("{}/Sessions/Logout", url);
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Logging out");
|
||||||
|
|
||||||
|
// Build auth header
|
||||||
|
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
let request = self.http_client.client.post(&endpoint)
|
||||||
|
.header("X-Emby-Authorization", auth_header)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
|
// Don't retry logout - if it fails, we'll still clear local state
|
||||||
|
match self.http_client.client.execute(request).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().is_success() {
|
||||||
|
log::info!("[AuthManager] Logout successful");
|
||||||
|
} else {
|
||||||
|
log::warn!("[AuthManager] Logout request failed: {}", response.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[AuthManager] Logout request failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current session
|
||||||
|
pub async fn get_session(&self) -> Option<Session> {
|
||||||
|
self.current_session.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set current session
|
||||||
|
pub async fn set_session(&self, session: Option<Session>) {
|
||||||
|
*self.current_session.write().await = session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test URL normalization - adds https:// when missing
|
||||||
|
///
|
||||||
|
/// Ensures that URLs without protocol are normalized to https://
|
||||||
|
/// This prevents "builder error" when constructing HTTP requests.
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_adds_https() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("jellyfin.example.com"),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("192.168.1.100:8096"),
|
||||||
|
"https://192.168.1.100:8096"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test URL normalization - preserves existing protocol
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_preserves_protocol() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("https://jellyfin.example.com"),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("http://localhost:8096"),
|
||||||
|
"http://localhost:8096"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test URL normalization - removes trailing slash
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_removes_trailing_slash() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("https://jellyfin.example.com/"),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("jellyfin.example.com/"),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test URL normalization - trims whitespace
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_trims_whitespace() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url(" jellyfin.example.com "),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test URL normalization - complex case
|
||||||
|
///
|
||||||
|
/// This is the bug that caused the login issue: user enters URL
|
||||||
|
/// without protocol, it gets stored in DB, then fails when building
|
||||||
|
/// HTTP requests.
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_real_world_case() {
|
||||||
|
// User input: "jellyfin.tourolle.paris"
|
||||||
|
let input = "jellyfin.tourolle.paris";
|
||||||
|
let normalized = AuthManager::normalize_url(input);
|
||||||
|
|
||||||
|
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
||||||
|
assert!(normalized.starts_with("https://"));
|
||||||
|
assert!(!normalized.ends_with('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src-tauri/src/auth/session_verifier.rs
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::{AuthManager, User};
|
||||||
|
|
||||||
|
// Verification interval (5 minutes)
|
||||||
|
const VERIFICATION_INTERVAL_MS: u64 = 300000;
|
||||||
|
|
||||||
|
/// Session verification result event emitted to frontend
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
|
pub enum SessionVerificationEvent {
|
||||||
|
Verified { user: User },
|
||||||
|
NeedsReauth { reason: String },
|
||||||
|
NetworkError { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background session verifier
|
||||||
|
pub struct SessionVerifier {
|
||||||
|
auth_manager: Arc<AuthManager>,
|
||||||
|
is_running: Arc<AtomicBool>,
|
||||||
|
device_id: String,
|
||||||
|
app_handle: Option<AppHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionVerifier {
|
||||||
|
/// Create a new session verifier
|
||||||
|
pub fn new(auth_manager: Arc<AuthManager>, device_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
auth_manager,
|
||||||
|
is_running: Arc::new(AtomicBool::new(false)),
|
||||||
|
device_id,
|
||||||
|
app_handle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the Tauri app handle for event emission
|
||||||
|
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
|
||||||
|
self.app_handle = Some(app_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start periodic session verification
|
||||||
|
pub async fn start(&self) {
|
||||||
|
if self.is_running.swap(true, Ordering::SeqCst) {
|
||||||
|
log::info!("[SessionVerifier] Already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[SessionVerifier] Starting background verification");
|
||||||
|
|
||||||
|
let auth_manager = Arc::clone(&self.auth_manager);
|
||||||
|
let is_running = Arc::clone(&self.is_running);
|
||||||
|
let device_id = self.device_id.clone();
|
||||||
|
let app_handle = self.app_handle.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Initial verification after short delay
|
||||||
|
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||||
|
|
||||||
|
while is_running.load(Ordering::SeqCst) {
|
||||||
|
// Get current session
|
||||||
|
let session = auth_manager.get_session().await;
|
||||||
|
|
||||||
|
if let Some(session) = session {
|
||||||
|
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
|
||||||
|
|
||||||
|
// Verify the session
|
||||||
|
match auth_manager
|
||||||
|
.verify_session(
|
||||||
|
&session.server_url,
|
||||||
|
&session.user_id,
|
||||||
|
&session.access_token,
|
||||||
|
&device_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(user) => {
|
||||||
|
log::info!("[SessionVerifier] Session verified successfully");
|
||||||
|
|
||||||
|
// Emit success event
|
||||||
|
if let Some(app) = &app_handle {
|
||||||
|
let event = SessionVerificationEvent::Verified { user };
|
||||||
|
if let Err(e) = app.emit("auth:session-verified", event) {
|
||||||
|
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session as verified
|
||||||
|
let mut updated_session = session;
|
||||||
|
updated_session.verified = true;
|
||||||
|
updated_session.needs_reauth = false;
|
||||||
|
auth_manager.set_session(Some(updated_session)).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[SessionVerifier] Verification failed: {}", e);
|
||||||
|
|
||||||
|
// Classify error
|
||||||
|
let is_auth_error = e.contains("401") || e.contains("403");
|
||||||
|
let is_network_error = e.contains("network")
|
||||||
|
|| e.contains("timeout")
|
||||||
|
|| e.contains("connection")
|
||||||
|
|| e.contains("DNS");
|
||||||
|
|
||||||
|
if is_auth_error {
|
||||||
|
// Token is invalid - need re-authentication
|
||||||
|
log::warn!("[SessionVerifier] Session requires re-authentication");
|
||||||
|
|
||||||
|
if let Some(app) = &app_handle {
|
||||||
|
let event = SessionVerificationEvent::NeedsReauth {
|
||||||
|
reason: "Session expired".to_string(),
|
||||||
|
};
|
||||||
|
if let Err(e) = app.emit("auth:needs-reauth", event) {
|
||||||
|
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
let mut updated_session = session;
|
||||||
|
updated_session.verified = false;
|
||||||
|
updated_session.needs_reauth = true;
|
||||||
|
auth_manager.set_session(Some(updated_session)).await;
|
||||||
|
} else if is_network_error {
|
||||||
|
// Network error - keep using cached session
|
||||||
|
log::info!("[SessionVerifier] Network error during verification, keeping cached session");
|
||||||
|
|
||||||
|
if let Some(app) = &app_handle {
|
||||||
|
let event = SessionVerificationEvent::NetworkError {
|
||||||
|
message: e.clone(),
|
||||||
|
};
|
||||||
|
if let Err(e) = app.emit("auth:network-error", event) {
|
||||||
|
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown error - log but don't invalidate
|
||||||
|
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for next verification
|
||||||
|
tokio::time::sleep(Duration::from_millis(VERIFICATION_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[SessionVerifier] Stopped");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop periodic verification
|
||||||
|
pub fn stop(&self) {
|
||||||
|
log::info!("[SessionVerifier] Stopping background verification");
|
||||||
|
self.is_running.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
239
src-tauri/src/commands/auth.rs
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
|
||||||
|
|
||||||
|
/// Wrapper for AuthManager to manage in Tauri state
|
||||||
|
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
||||||
|
|
||||||
|
/// Wrapper for SessionVerifier to manage in Tauri state
|
||||||
|
pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerifier>>>);
|
||||||
|
|
||||||
|
/// Initialize the auth manager (call on app startup)
|
||||||
|
/// Restores session from storage if available
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_initialize(
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
database: State<'_, crate::commands::DatabaseWrapper>,
|
||||||
|
credentials: State<'_, crate::commands::CredentialStoreWrapper>,
|
||||||
|
) -> Result<Option<Session>, String> {
|
||||||
|
// First check if we already have a session in memory
|
||||||
|
if let Some(session) = auth_manager.0.get_session().await {
|
||||||
|
return Ok(Some(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to restore session from storage
|
||||||
|
log::info!("[AuthManager] Restoring session from storage...");
|
||||||
|
|
||||||
|
// Use the existing storage_get_active_session function
|
||||||
|
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||||
|
Ok(Some(session)) => session,
|
||||||
|
Ok(None) => {
|
||||||
|
log::info!("[AuthManager] No active session in storage");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create session object from active session with normalized URL
|
||||||
|
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
|
||||||
|
|
||||||
|
let session = Session {
|
||||||
|
user_id: active_session.user_id,
|
||||||
|
username: active_session.username,
|
||||||
|
server_id: active_session.server_id,
|
||||||
|
server_url: normalized_url,
|
||||||
|
server_name: active_session.server_name,
|
||||||
|
access_token: active_session.access_token,
|
||||||
|
verified: false, // Will be verified in background
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in AuthManager
|
||||||
|
auth_manager.0.set_session(Some(session.clone())).await;
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
|
||||||
|
Ok(Some(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a Jellyfin server and get server info
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_connect_to_server(
|
||||||
|
server_url: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<ServerInfo, String> {
|
||||||
|
auth_manager.0.connect_to_server(&server_url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login with username and password
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
device_id: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<AuthResult, String> {
|
||||||
|
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||||
|
|
||||||
|
// Create session from auth result with normalized URL
|
||||||
|
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url);
|
||||||
|
|
||||||
|
let session = Session {
|
||||||
|
user_id: result.user.id.clone(),
|
||||||
|
username: result.user.name.clone(),
|
||||||
|
server_id: result.server_id.clone(),
|
||||||
|
server_url: normalized_url,
|
||||||
|
server_name: String::new(), // Will be set by frontend
|
||||||
|
access_token: result.access_token.clone(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
auth_manager.0.set_session(Some(session)).await;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify current session
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_verify_session(
|
||||||
|
server_url: String,
|
||||||
|
user_id: String,
|
||||||
|
access_token: String,
|
||||||
|
device_id: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout (clear session and call Jellyfin logout endpoint)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_logout(
|
||||||
|
server_url: String,
|
||||||
|
access_token: String,
|
||||||
|
device_id: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Stop session verification
|
||||||
|
let mut verifier_guard = session_verifier.0.lock().await;
|
||||||
|
if let Some(verifier) = verifier_guard.take() {
|
||||||
|
verifier.stop();
|
||||||
|
}
|
||||||
|
drop(verifier_guard);
|
||||||
|
|
||||||
|
// Call Jellyfin logout endpoint
|
||||||
|
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
|
||||||
|
|
||||||
|
// Clear session
|
||||||
|
auth_manager.0.set_session(None).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current session
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_get_session(
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<Option<Session>, String> {
|
||||||
|
Ok(auth_manager.0.get_session().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set current session (for restoration from storage)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_set_session(
|
||||||
|
session: Option<Session>,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Normalize the server URL if session is provided
|
||||||
|
let normalized_session = session.map(|mut s| {
|
||||||
|
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
|
||||||
|
s
|
||||||
|
});
|
||||||
|
|
||||||
|
auth_manager.0.set_session(normalized_session).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start background session verification
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_start_verification(
|
||||||
|
device_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut verifier_guard = session_verifier.0.lock().await;
|
||||||
|
|
||||||
|
// Stop existing verifier if any
|
||||||
|
if let Some(verifier) = verifier_guard.take() {
|
||||||
|
verifier.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get AuthManager Arc
|
||||||
|
let manager = auth_manager.0.clone();
|
||||||
|
|
||||||
|
// Create new verifier
|
||||||
|
let mut verifier = SessionVerifier::new(manager, device_id);
|
||||||
|
verifier.set_app_handle(app_handle);
|
||||||
|
verifier.start().await;
|
||||||
|
|
||||||
|
*verifier_guard = Some(verifier);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop background session verification
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_stop_verification(
|
||||||
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut verifier_guard = session_verifier.0.lock().await;
|
||||||
|
|
||||||
|
if let Some(verifier) = verifier_guard.take() {
|
||||||
|
verifier.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-authenticate with password (when session expired)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_reauthenticate(
|
||||||
|
password: String,
|
||||||
|
device_id: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<AuthResult, String> {
|
||||||
|
// Get current session to extract server_url and username
|
||||||
|
let session = auth_manager.0.get_session().await
|
||||||
|
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
||||||
|
|
||||||
|
// Re-login with stored credentials
|
||||||
|
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
|
||||||
|
|
||||||
|
// Update session with new token
|
||||||
|
let updated_session = Session {
|
||||||
|
user_id: result.user.id.clone(),
|
||||||
|
username: result.user.name.clone(),
|
||||||
|
server_id: result.server_id.clone(),
|
||||||
|
server_url: session.server_url,
|
||||||
|
server_name: session.server_name,
|
||||||
|
access_token: result.access_token.clone(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
auth_manager.0.set_session(Some(updated_session)).await;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
76
src-tauri/src/commands/connectivity.rs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
||||||
|
|
||||||
|
/// Wrapper for ConnectivityMonitor managed state
|
||||||
|
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
|
||||||
|
|
||||||
|
/// Check if the server is currently reachable
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connectivity_check_server(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
Ok(monitor.check_reachability().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the server URL and trigger an immediate check
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connectivity_set_server_url(
|
||||||
|
url: String,
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.set_server_url(url).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current connectivity status
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connectivity_get_status(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<ConnectivityStatus, String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
Ok(monitor.get_status().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start monitoring connectivity with adaptive polling
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connectivity_start_monitoring(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.start_monitoring().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop monitoring connectivity
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connectivity_stop_monitoring(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.stop_monitoring();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the server as reachable (called after successful API calls)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connectivity_mark_reachable(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.mark_reachable().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the server as unreachable (called after failed API calls)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connectivity_mark_unreachable(
|
||||||
|
error: Option<String>,
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.mark_unreachable(error).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
74
src-tauri/src/commands/conversions.rs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
//! Tauri commands for unit conversions and formatting
|
||||||
|
//!
|
||||||
|
//! These commands expose conversion utilities to the frontend,
|
||||||
|
//! allowing centralized conversion logic in Rust.
|
||||||
|
|
||||||
|
use crate::utils::conversions::{
|
||||||
|
format_time, format_time_long, calculate_progress,
|
||||||
|
ticks_to_seconds, percent_to_volume,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Format time in seconds to MM:SS display string
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `seconds` - Time in seconds
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Formatted string like "3:45" or "12:09"
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn format_time_seconds(seconds: f64) -> String {
|
||||||
|
format_time(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format time in seconds to HH:MM:SS or MM:SS display string
|
||||||
|
///
|
||||||
|
/// Automatically chooses format based on duration:
|
||||||
|
/// - Less than 1 hour: Returns MM:SS format
|
||||||
|
/// - 1 hour or more: Returns HH:MM:SS format
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `seconds` - Time in seconds
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Formatted string like "1:23:45" or "3:45"
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn format_time_seconds_long(seconds: f64) -> String {
|
||||||
|
format_time_long(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Jellyfin ticks to seconds
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `ticks` - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Time in seconds
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
||||||
|
ticks_to_seconds(ticks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate progress percentage from position and duration
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `position` - Current position in seconds
|
||||||
|
/// * `duration` - Total duration in seconds
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Progress as percentage (0.0 to 100.0)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
||||||
|
calculate_progress(position, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert percentage volume (0-100) to normalized (0.0-1.0)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `percent` - Volume as percentage (0 to 100)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Normalized volume (0.0 to 1.0)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn convert_percent_to_volume(percent: f64) -> f64 {
|
||||||
|
percent_to_volume(percent)
|
||||||
|
}
|
||||||
2073
src-tauri/src/commands/download.rs
Normal file
26
src-tauri/src/commands/mod.rs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod connectivity;
|
||||||
|
pub mod conversions;
|
||||||
|
pub mod download;
|
||||||
|
pub mod offline;
|
||||||
|
pub mod playback_mode;
|
||||||
|
pub mod playback_reporting;
|
||||||
|
pub mod player;
|
||||||
|
pub mod repository;
|
||||||
|
pub mod sessions;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
|
pub use auth::*;
|
||||||
|
pub use connectivity::*;
|
||||||
|
pub use conversions::*;
|
||||||
|
pub use download::*;
|
||||||
|
pub use offline::*;
|
||||||
|
pub use playback_mode::*;
|
||||||
|
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||||
|
pub use playback_reporting::*;
|
||||||
|
pub use player::*;
|
||||||
|
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
||||||
|
pub use sessions::*;
|
||||||
|
pub use storage::*;
|
||||||
|
pub use sync::*;
|
||||||
154
src-tauri/src/commands/offline.rs
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
//! Tauri commands for offline data access
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::DatabaseWrapper;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OfflineItem {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub item_type: String,
|
||||||
|
pub album_id: Option<String>,
|
||||||
|
pub album_name: Option<String>,
|
||||||
|
pub artists: Option<String>,
|
||||||
|
pub runtime_ticks: Option<i64>,
|
||||||
|
pub primary_image_tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an item is available offline
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn offline_is_available(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT COUNT(*) FROM downloads WHERE item_id = ? AND status = 'completed'",
|
||||||
|
vec![QueryParam::String(item_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let count: i64 = db_service
|
||||||
|
.query_one(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all offline items for a user
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn offline_get_items(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<Vec<OfflineItem>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
|
||||||
|
i.runtime_ticks, i.primary_image_tag
|
||||||
|
FROM items i
|
||||||
|
INNER JOIN downloads d ON i.id = d.item_id
|
||||||
|
WHERE d.user_id = ? AND d.status = 'completed'
|
||||||
|
ORDER BY d.completed_at DESC",
|
||||||
|
vec![QueryParam::String(user_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service
|
||||||
|
.query_many(query, |row| {
|
||||||
|
Ok(OfflineItem {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
item_type: row.get(2)?,
|
||||||
|
album_id: row.get(3)?,
|
||||||
|
album_name: row.get(4)?,
|
||||||
|
artists: row.get(5)?,
|
||||||
|
runtime_ticks: row.get(6)?,
|
||||||
|
primary_image_tag: row.get(7)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search offline items
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn offline_search(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
query: String,
|
||||||
|
) -> Result<Vec<OfflineItem>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_query = format!("%{}%", query.to_lowercase());
|
||||||
|
|
||||||
|
let db_query = Query::with_params(
|
||||||
|
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
|
||||||
|
i.runtime_ticks, i.primary_image_tag
|
||||||
|
FROM items i
|
||||||
|
INNER JOIN downloads d ON i.id = d.item_id
|
||||||
|
WHERE d.user_id = ? AND d.status = 'completed'
|
||||||
|
AND (LOWER(i.name) LIKE ? OR LOWER(i.artists) LIKE ? OR LOWER(i.album_name) LIKE ?)
|
||||||
|
ORDER BY i.name
|
||||||
|
LIMIT 50",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id),
|
||||||
|
QueryParam::String(search_query.clone()),
|
||||||
|
QueryParam::String(search_query.clone()),
|
||||||
|
QueryParam::String(search_query),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service
|
||||||
|
.query_many(db_query, |row| {
|
||||||
|
Ok(OfflineItem {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
item_type: row.get(2)?,
|
||||||
|
album_id: row.get(3)?,
|
||||||
|
album_name: row.get(4)?,
|
||||||
|
artists: row.get(5)?,
|
||||||
|
runtime_ticks: row.get(6)?,
|
||||||
|
primary_image_tag: row.get(7)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_offline_item_serialization() {
|
||||||
|
let item = OfflineItem {
|
||||||
|
id: "123".to_string(),
|
||||||
|
name: "Test Song".to_string(),
|
||||||
|
item_type: "Audio".to_string(),
|
||||||
|
album_id: Some("album1".to_string()),
|
||||||
|
album_name: Some("Test Album".to_string()),
|
||||||
|
artists: Some("Artist 1".to_string()),
|
||||||
|
runtime_ticks: Some(180000000),
|
||||||
|
primary_image_tag: Some("tag123".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
|
assert!(json.contains("\"itemType\":\"Audio\""));
|
||||||
|
assert!(json.contains("\"albumName\":\"Test Album\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src-tauri/src/commands/playback_mode.rs
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
|
||||||
|
|
||||||
|
/// Wrapper for PlaybackModeManager to manage in Tauri state
|
||||||
|
pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
|
||||||
|
|
||||||
|
/// Get the current playback mode
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn playback_mode_get_current(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
) -> Result<PlaybackMode, String> {
|
||||||
|
Ok(manager.0.get_mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the playback mode (internal/testing use)
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn playback_mode_set(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
mode: PlaybackMode,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
manager.0.set_mode(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if currently transferring between playback modes
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn playback_mode_is_transferring(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
Ok(manager.0.is_transferring())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer playback from local device to a remote Jellyfin session
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_mode_transfer_to_remote(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackModeCommands] Transferring to remote session: {}",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
manager.0.transfer_to_remote(session_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer playback from remote session back to local device
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
/// - current_item_id: The Jellyfin item ID currently playing on remote
|
||||||
|
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_mode_transfer_to_local(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
current_item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackModeCommands] Transferring to local: item_id={}, position={}",
|
||||||
|
current_item_id,
|
||||||
|
position_ticks
|
||||||
|
);
|
||||||
|
manager
|
||||||
|
.0
|
||||||
|
.transfer_to_local(current_item_id, position_ticks)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get remote session status (for polling position/duration)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_mode_get_remote_status(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
player: State<'_, crate::commands::PlayerStateWrapper>,
|
||||||
|
) -> Result<RemoteSessionStatus, String> {
|
||||||
|
let mode = manager.0.get_mode();
|
||||||
|
|
||||||
|
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
|
||||||
|
// Get Jellyfin client from player controller - clone before await
|
||||||
|
let client = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let client_arc = controller.jellyfin_client();
|
||||||
|
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
|
||||||
|
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get session info
|
||||||
|
match client.get_session(&session_id).await {
|
||||||
|
Ok(Some(session)) => {
|
||||||
|
let position_ticks = session.play_state.as_ref()
|
||||||
|
.and_then(|ps| ps.position_ticks)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let duration_ticks = session.now_playing_item.as_ref()
|
||||||
|
.and_then(|item| item.run_time_ticks)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let is_paused = session.play_state.as_ref()
|
||||||
|
.and_then(|ps| ps.is_paused)
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
Ok(RemoteSessionStatus {
|
||||||
|
position: position_ticks as f64 / 10_000_000.0,
|
||||||
|
duration: if duration_ticks > 0 {
|
||||||
|
Some(duration_ticks as f64 / 10_000_000.0)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
is_playing: !is_paused,
|
||||||
|
now_playing_item: session.now_playing_item.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(None) => Err("Remote session not found".to_string()),
|
||||||
|
Err(e) => Err(format!("Failed to get session status: {}", e)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err("Not in remote playback mode".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remote session status for UI updates
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RemoteSessionStatus {
|
||||||
|
pub position: f64,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub is_playing: bool,
|
||||||
|
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
|
||||||
|
}
|
||||||
184
src-tauri/src/commands/playback_reporting.rs
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
//! Tauri commands for playback reporting operations
|
||||||
|
//!
|
||||||
|
//! These commands provide frontend access to the Rust playback reporting system,
|
||||||
|
//! replacing the TypeScript implementation with native Rust reporting.
|
||||||
|
//!
|
||||||
|
//! Commands are registered but not yet called from the frontend.
|
||||||
|
//! Dead code warnings are suppressed until frontend migration is complete.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
use crate::commands::connectivity::ConnectivityMonitorWrapper;
|
||||||
|
use crate::commands::storage::DatabaseWrapper;
|
||||||
|
use crate::jellyfin::client::JellyfinClient;
|
||||||
|
use crate::jellyfin::JellyfinConfig;
|
||||||
|
use crate::playback_reporting::{PlaybackReporter, PlaybackOperation, PlaybackContext};
|
||||||
|
use crate::utils::conversions::seconds_to_ticks;
|
||||||
|
|
||||||
|
/// Tauri state wrapper for PlaybackReporter
|
||||||
|
pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>);
|
||||||
|
|
||||||
|
/// Initialize playback reporter (called after login)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_reporter_init(
|
||||||
|
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
server_url: String,
|
||||||
|
user_id: String,
|
||||||
|
access_token: String,
|
||||||
|
device_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[PlaybackReporter] Initializing for user: {}", user_id);
|
||||||
|
|
||||||
|
// Get database service
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create JellyfinClient
|
||||||
|
let jellyfin_config = JellyfinConfig {
|
||||||
|
server_url,
|
||||||
|
access_token,
|
||||||
|
device_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let jellyfin_client = JellyfinClient::new(jellyfin_config)
|
||||||
|
.map_err(|e| format!("Failed to create JellyfinClient: {}", e))?;
|
||||||
|
|
||||||
|
// Create PlaybackReporter
|
||||||
|
let reporter = PlaybackReporter::new(
|
||||||
|
db_service,
|
||||||
|
Arc::new(TokioMutex::new(Some(jellyfin_client))),
|
||||||
|
user_id.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store in wrapper
|
||||||
|
*reporter_wrapper.0.lock().await = Some(reporter);
|
||||||
|
|
||||||
|
log::info!("[PlaybackReporter] Initialized successfully for user: {}", user_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy playback reporter (called on logout)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_reporter_destroy(
|
||||||
|
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[PlaybackReporter] Destroying reporter");
|
||||||
|
*reporter_wrapper.0.lock().await = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback start
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_report_start(
|
||||||
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
position_seconds: f64,
|
||||||
|
context_type: Option<String>,
|
||||||
|
context_id: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let reporter_guard = reporter.0.lock().await;
|
||||||
|
let reporter_instance = reporter_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("PlaybackReporter not initialized")?;
|
||||||
|
|
||||||
|
let position_ticks = seconds_to_ticks(position_seconds);
|
||||||
|
let context = context_type.map(|ct| PlaybackContext {
|
||||||
|
context_type: ct,
|
||||||
|
context_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let operation = PlaybackOperation::Start {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
|
||||||
|
let monitor = connectivity.0.lock().await;
|
||||||
|
let is_online = monitor.get_status().await.is_server_reachable;
|
||||||
|
drop(monitor);
|
||||||
|
|
||||||
|
reporter_instance.report(operation, is_online).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback progress
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_report_progress(
|
||||||
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
position_seconds: f64,
|
||||||
|
is_paused: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let reporter_guard = reporter.0.lock().await;
|
||||||
|
let reporter_instance = reporter_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("PlaybackReporter not initialized")?;
|
||||||
|
|
||||||
|
let position_ticks = seconds_to_ticks(position_seconds);
|
||||||
|
let operation = PlaybackOperation::Progress {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
is_paused,
|
||||||
|
};
|
||||||
|
|
||||||
|
let monitor = connectivity.0.lock().await;
|
||||||
|
let is_online = monitor.get_status().await.is_server_reachable;
|
||||||
|
drop(monitor);
|
||||||
|
|
||||||
|
reporter_instance.report(operation, is_online).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback stopped
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_report_stopped(
|
||||||
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
position_seconds: f64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let reporter_guard = reporter.0.lock().await;
|
||||||
|
let reporter_instance = reporter_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("PlaybackReporter not initialized")?;
|
||||||
|
|
||||||
|
let position_ticks = seconds_to_ticks(position_seconds);
|
||||||
|
let operation = PlaybackOperation::Stopped {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
};
|
||||||
|
|
||||||
|
let monitor = connectivity.0.lock().await;
|
||||||
|
let is_online = monitor.get_status().await.is_server_reachable;
|
||||||
|
drop(monitor);
|
||||||
|
|
||||||
|
reporter_instance.report(operation, is_online).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark item as played
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn playback_mark_played(
|
||||||
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let reporter_guard = reporter.0.lock().await;
|
||||||
|
let reporter_instance = reporter_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("PlaybackReporter not initialized")?;
|
||||||
|
|
||||||
|
let operation = PlaybackOperation::MarkPlayed { item_id };
|
||||||
|
|
||||||
|
let monitor = connectivity.0.lock().await;
|
||||||
|
let is_online = monitor.get_status().await.is_server_reachable;
|
||||||
|
drop(monitor);
|
||||||
|
|
||||||
|
reporter_instance.report(operation, is_online).await
|
||||||
|
}
|
||||||
2700
src-tauri/src/commands/player.rs
Normal file
435
src-tauri/src/commands/repository.rs
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
// Tauri commands for repository access
|
||||||
|
// Uses handle-based system: UUID -> Arc<HybridRepository>
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use log::{debug, error, info};
|
||||||
|
use tauri::State;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::jellyfin::HttpClient;
|
||||||
|
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
|
||||||
|
|
||||||
|
/// Repository handle manager
|
||||||
|
pub struct RepositoryManager {
|
||||||
|
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepositoryManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
repositories: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(&self, handle: String, repository: HybridRepository) {
|
||||||
|
let mut repos = self.repositories.lock().unwrap();
|
||||||
|
repos.insert(handle, Arc::new(repository));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
|
||||||
|
let repos = self.repositories.lock().unwrap();
|
||||||
|
repos.get(handle).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn destroy(&self, handle: &str) {
|
||||||
|
let mut repos = self.repositories.lock().unwrap();
|
||||||
|
repos.remove(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper for Tauri state
|
||||||
|
pub struct RepositoryManagerWrapper(pub RepositoryManager);
|
||||||
|
|
||||||
|
/// Create a new repository instance
|
||||||
|
/// Returns a handle (UUID) for accessing the repository
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_create(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
db: State<'_, crate::commands::storage::DatabaseWrapper>,
|
||||||
|
server_url: String,
|
||||||
|
user_id: String,
|
||||||
|
access_token: String,
|
||||||
|
server_id: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
info!("[REPO] repository_create called for user: {}", user_id);
|
||||||
|
|
||||||
|
// Create HTTP client for online repository
|
||||||
|
debug!("[REPO] Creating HTTP client...");
|
||||||
|
let http_config = crate::jellyfin::HttpConfig::default();
|
||||||
|
let http_client = HttpClient::new(http_config).map_err(|e| {
|
||||||
|
error!("[REPO] HTTP client creation failed: {}", e);
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
debug!("[REPO] HTTP client created successfully");
|
||||||
|
|
||||||
|
// Create online repository
|
||||||
|
debug!("[REPO] Creating online repository...");
|
||||||
|
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token);
|
||||||
|
debug!("[REPO] Online repository created");
|
||||||
|
|
||||||
|
// Create offline repository with async-safe database service
|
||||||
|
debug!("[REPO] Creating database service...");
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| {
|
||||||
|
error!("[REPO] Database lock failed: {}", e);
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
debug!("[REPO] Database lock acquired, getting service...");
|
||||||
|
Arc::new(database.service())
|
||||||
|
}; // Lock is released here
|
||||||
|
debug!("[REPO] Database service created");
|
||||||
|
|
||||||
|
debug!("[REPO] Creating offline repository...");
|
||||||
|
let offline = OfflineRepository::new(db_service, server_id, user_id);
|
||||||
|
debug!("[REPO] Offline repository created");
|
||||||
|
|
||||||
|
// Create hybrid repository
|
||||||
|
debug!("[REPO] Creating hybrid repository...");
|
||||||
|
let hybrid = HybridRepository::new(online, offline);
|
||||||
|
debug!("[REPO] Hybrid repository created");
|
||||||
|
|
||||||
|
// Generate handle and store repository
|
||||||
|
let uuid = Uuid::new_v4();
|
||||||
|
let handle = format!("{}", uuid);
|
||||||
|
info!("[REPO] Generated handle: {}", handle);
|
||||||
|
|
||||||
|
// Store repository synchronously
|
||||||
|
debug!("[REPO] Storing repository...");
|
||||||
|
manager.0.create(handle.clone(), hybrid);
|
||||||
|
info!("[REPO] Repository stored successfully");
|
||||||
|
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy a repository instance
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_destroy(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
manager.0.destroy(&handle);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get libraries
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_libraries(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<Vec<Library>, String> {
|
||||||
|
debug!("[REPO] get_libraries called with handle: {}", handle);
|
||||||
|
let repo = manager.0.get(&handle).ok_or_else(|| {
|
||||||
|
error!("[REPO] Repository not found for handle: {}", handle);
|
||||||
|
"Repository not found".to_string()
|
||||||
|
})?;
|
||||||
|
debug!("[REPO] Repository found, fetching libraries...");
|
||||||
|
repo.as_ref().get_libraries()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||||
|
format!("{:?}", e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get items in a container (library, folder, album, etc.)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_items(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
parent_id: String,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_items(&parent_id, options)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single item by ID
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_item(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<MediaItem, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_item(&item_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get latest items in a library
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_latest_items(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
parent_id: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_latest_items(&parent_id, limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get resume items (continue watching/listening)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_resume_items(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
parent_id: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
|
debug!("[REPO] get_resume_items called with handle: {}", handle);
|
||||||
|
let repo = manager.0.get(&handle).ok_or_else(|| {
|
||||||
|
error!("[REPO] Repository not found for handle: {}", handle);
|
||||||
|
"Repository not found".to_string()
|
||||||
|
})?;
|
||||||
|
debug!("[REPO] Repository found, fetching resume items...");
|
||||||
|
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("[REPO] Error fetching resume items: {:?}", e);
|
||||||
|
format!("{:?}", e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get next up episodes
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_next_up_episodes(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
series_id: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_next_up_episodes(series_id.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recently played audio
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_recently_played_audio(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_recently_played_audio(limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get resume movies
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_resume_movies(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_resume_movies(limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get genres for a library
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_genres(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
parent_id: Option<String>,
|
||||||
|
) -> Result<Vec<Genre>, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_genres(parent_id.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search for items
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_search(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
query: String,
|
||||||
|
options: Option<SearchOptions>,
|
||||||
|
) -> Result<SearchResult, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().search(&query, options)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get playback info for an item
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_playback_info(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<PlaybackInfo, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_playback_info(&item_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get video stream URL with optional seeking support
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_video_stream_url(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
media_source_id: Option<String>,
|
||||||
|
start_time_seconds: Option<f64>,
|
||||||
|
audio_stream_index: Option<i32>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref()
|
||||||
|
.get_video_stream_url(
|
||||||
|
&item_id,
|
||||||
|
media_source_id.as_deref(),
|
||||||
|
start_time_seconds,
|
||||||
|
audio_stream_index,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audio stream URL for a track
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_audio_stream_url(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref()
|
||||||
|
.get_audio_stream_url(&item_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback start
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_report_playback_start(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().report_playback_start(&item_id, position_ticks)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback progress
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_report_playback_progress(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().report_playback_progress(&item_id, position_ticks)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback stopped
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_report_playback_stopped(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().report_playback_stopped(&item_id, position_ticks)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get image URL for an item
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn repository_get_image_url(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
image_type: ImageType,
|
||||||
|
options: Option<ImageOptions>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an item as favorite
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_mark_favorite(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().mark_favorite(&item_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unmark an item as favorite
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_unmark_favorite(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().unmark_favorite(&item_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get person details
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_person(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
person_id: String,
|
||||||
|
) -> Result<MediaItem, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_person(&person_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get items by person (actor, director, etc.)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_items_by_person(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
person_id: String,
|
||||||
|
options: Option<GetItemsOptions>,
|
||||||
|
) -> Result<SearchResult, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_items_by_person(&person_id, options)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get similar/related items for a media item
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn repository_get_similar_items(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
item_id: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<SearchResult, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_similar_items(&item_id, limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
32
src-tauri/src/commands/sessions.rs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::session_poller::{PollingHint, SessionPollerManager};
|
||||||
|
use crate::jellyfin::client::SessionInfo;
|
||||||
|
|
||||||
|
/// Tauri state wrapper for SessionPollerManager
|
||||||
|
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
|
||||||
|
|
||||||
|
/// Set polling frequency hint based on UI state
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn sessions_set_polling_hint(
|
||||||
|
poller: State<'_, SessionPollerWrapper>,
|
||||||
|
hint: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let parsed_hint = match hint.as_str() {
|
||||||
|
"cast_active" => PollingHint::CastActive,
|
||||||
|
"cast_discovery" => PollingHint::CastDiscovery,
|
||||||
|
"normal" => PollingHint::Normal,
|
||||||
|
_ => return Err(format!("Invalid polling hint: {}", hint)),
|
||||||
|
};
|
||||||
|
|
||||||
|
poller.0.set_polling_hint(parsed_hint);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually trigger a session poll (for refresh button)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sessions_poll_now(
|
||||||
|
poller: State<'_, SessionPollerWrapper>,
|
||||||
|
) -> Result<Vec<SessionInfo>, String> {
|
||||||
|
poller.0.poll_now().await
|
||||||
|
}
|
||||||
1719
src-tauri/src/commands/storage.rs
Normal file
235
src-tauri/src/commands/sync.rs
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
//! Tauri commands for sync queue operations
|
||||||
|
//!
|
||||||
|
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
||||||
|
//! that need to be synced to the Jellyfin server when connectivity is restored.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::storage::DatabaseWrapper;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// Sync queue item returned to frontend
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncQueueItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub user_id: String,
|
||||||
|
pub operation: String,
|
||||||
|
pub item_id: Option<String>,
|
||||||
|
pub payload: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub retry_count: i32,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a mutation for sync to server
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_queue_mutation(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
operation: String,
|
||||||
|
item_id: Option<String>,
|
||||||
|
payload: Option<String>,
|
||||||
|
) -> Result<i64, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id),
|
||||||
|
QueryParam::String(operation),
|
||||||
|
item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
let id = db_service.last_insert_rowid().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all pending sync operations for a user
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_get_pending(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
limit: Option<i32>,
|
||||||
|
) -> Result<Vec<SyncQueueItem>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql = if let Some(l) = limit {
|
||||||
|
format!(
|
||||||
|
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
||||||
|
FROM sync_queue
|
||||||
|
WHERE user_id = ? AND status IN ('pending', 'failed')
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT {}",
|
||||||
|
l
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
|
||||||
|
FROM sync_queue
|
||||||
|
WHERE user_id = ? AND status IN ('pending', 'failed')
|
||||||
|
ORDER BY created_at ASC".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
|
||||||
|
|
||||||
|
db_service
|
||||||
|
.query_many(query, |row| {
|
||||||
|
Ok(SyncQueueItem {
|
||||||
|
id: row.get(0)?,
|
||||||
|
user_id: row.get(1)?,
|
||||||
|
operation: row.get(2)?,
|
||||||
|
item_id: row.get(3)?,
|
||||||
|
payload: row.get(4)?,
|
||||||
|
status: row.get(5)?,
|
||||||
|
retry_count: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
error_message: row.get(8)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a sync operation as in progress
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_mark_processing(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE sync_queue SET status = 'processing' WHERE id = ?",
|
||||||
|
vec![QueryParam::Int64(id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a sync operation as completed
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_mark_completed(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE sync_queue SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
vec![QueryParam::Int64(id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a sync operation as failed with error message
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_mark_failed(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
id: i64,
|
||||||
|
error: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE sync_queue
|
||||||
|
SET status = 'failed',
|
||||||
|
retry_count = retry_count + 1,
|
||||||
|
error_message = ?,
|
||||||
|
processed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?",
|
||||||
|
vec![QueryParam::String(error), QueryParam::Int64(id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get count of pending sync operations for a user
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_get_pending_count(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<i32, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
|
||||||
|
vec![QueryParam::String(user_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service
|
||||||
|
.query_one(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete completed sync operations older than specified days
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_cleanup_completed(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
days_old: i32,
|
||||||
|
) -> Result<i32, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"DELETE FROM sync_queue
|
||||||
|
WHERE status = 'completed'
|
||||||
|
AND processed_at < datetime('now', ?)",
|
||||||
|
vec![QueryParam::String(format!("-{} days", days_old))],
|
||||||
|
);
|
||||||
|
|
||||||
|
let deleted = db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(deleted as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all sync operations for a user (used during logout)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sync_clear_user(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"DELETE FROM sync_queue WHERE user_id = ?",
|
||||||
|
vec![QueryParam::String(user_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
370
src-tauri/src/connectivity/mod.rs
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
use crate::jellyfin::http_client::HttpClient;
|
||||||
|
|
||||||
|
// Adaptive polling intervals (matches TypeScript)
|
||||||
|
const AUTO_CHECK_INTERVAL_MS: u64 = 30000; // 30 seconds when online
|
||||||
|
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
|
||||||
|
|
||||||
|
/// Connectivity status
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConnectivityStatus {
|
||||||
|
/// Whether the Jellyfin server is reachable
|
||||||
|
pub is_server_reachable: bool,
|
||||||
|
/// Last time we checked server reachability (ISO 8601 string)
|
||||||
|
pub last_checked: Option<String>,
|
||||||
|
/// Error message from last connectivity check
|
||||||
|
pub connection_error: Option<String>,
|
||||||
|
/// Whether we're currently checking connectivity
|
||||||
|
pub is_checking: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConnectivityStatus {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// Start optimistic - assume online until proven otherwise
|
||||||
|
// This prevents the app from appearing offline on startup
|
||||||
|
is_server_reachable: true,
|
||||||
|
last_checked: None,
|
||||||
|
connection_error: None,
|
||||||
|
is_checking: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connectivity change event emitted to frontend
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ConnectivityChangeEvent {
|
||||||
|
is_reachable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connectivity monitor for tracking server reachability
|
||||||
|
pub struct ConnectivityMonitor {
|
||||||
|
server_url: Arc<RwLock<Option<String>>>,
|
||||||
|
http_client: Arc<HttpClient>,
|
||||||
|
status: Arc<RwLock<ConnectivityStatus>>,
|
||||||
|
is_monitoring: Arc<AtomicBool>,
|
||||||
|
app_handle: Option<AppHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectivityMonitor {
|
||||||
|
/// Create a new connectivity monitor
|
||||||
|
pub fn new(http_client: HttpClient) -> Self {
|
||||||
|
Self {
|
||||||
|
server_url: Arc::new(RwLock::new(None)),
|
||||||
|
http_client: Arc::new(http_client),
|
||||||
|
status: Arc::new(RwLock::new(ConnectivityStatus::default())),
|
||||||
|
is_monitoring: Arc::new(AtomicBool::new(false)),
|
||||||
|
app_handle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the Tauri app handle for event emission
|
||||||
|
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
|
||||||
|
self.app_handle = Some(app_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the server URL
|
||||||
|
pub async fn set_server_url(&self, url: String) {
|
||||||
|
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
|
||||||
|
let mut server_url = self.server_url.write().await;
|
||||||
|
*server_url = Some(url.clone());
|
||||||
|
drop(server_url);
|
||||||
|
|
||||||
|
// Check new server immediately
|
||||||
|
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
|
||||||
|
let is_reachable = self.check_reachability().await;
|
||||||
|
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current connectivity status
|
||||||
|
pub async fn get_status(&self) -> ConnectivityStatus {
|
||||||
|
self.status.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the Jellyfin server is reachable
|
||||||
|
pub async fn check_reachability(&self) -> bool {
|
||||||
|
// Mark as checking
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.is_checking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_url = self.server_url.read().await.clone();
|
||||||
|
|
||||||
|
if server_url.is_none() {
|
||||||
|
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.is_server_reachable = false;
|
||||||
|
status.connection_error = Some("No server URL configured".to_string());
|
||||||
|
status.is_checking = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = server_url.unwrap();
|
||||||
|
let ping_url = format!("{}/System/Info/Public", url);
|
||||||
|
|
||||||
|
// Store previous reachability state
|
||||||
|
let was_reachable = {
|
||||||
|
let status = self.status.read().await;
|
||||||
|
status.is_server_reachable
|
||||||
|
};
|
||||||
|
|
||||||
|
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
|
||||||
|
|
||||||
|
// Attempt to ping the server
|
||||||
|
let is_reachable = self.http_client.ping(&ping_url).await;
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[ConnectivityMonitor] Ping result: {} (was: {})",
|
||||||
|
if is_reachable { "SUCCESS" } else { "FAILED" },
|
||||||
|
if was_reachable { "reachable" } else { "unreachable" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.is_server_reachable = is_reachable;
|
||||||
|
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
status.connection_error = if is_reachable {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("Server unreachable".to_string())
|
||||||
|
};
|
||||||
|
status.is_checking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit events if reachability changed
|
||||||
|
if is_reachable != was_reachable {
|
||||||
|
self.emit_connectivity_change(is_reachable).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit reconnection event
|
||||||
|
if is_reachable && !was_reachable {
|
||||||
|
self.emit_server_reconnected().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark server as reachable (called after successful API call)
|
||||||
|
pub async fn mark_reachable(&self) {
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
let was_reachable = status.is_server_reachable;
|
||||||
|
|
||||||
|
status.is_server_reachable = true;
|
||||||
|
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
status.connection_error = None;
|
||||||
|
|
||||||
|
drop(status);
|
||||||
|
|
||||||
|
if !was_reachable {
|
||||||
|
log::info!("[ConnectivityMonitor] Server marked as reachable (was unreachable)");
|
||||||
|
self.emit_connectivity_change(true).await;
|
||||||
|
self.emit_server_reconnected().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark server as unreachable (called after failed API call)
|
||||||
|
pub async fn mark_unreachable(&self, error: Option<String>) {
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
let was_reachable = status.is_server_reachable;
|
||||||
|
|
||||||
|
status.is_server_reachable = false;
|
||||||
|
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
status.connection_error = error.or_else(|| Some("Server unreachable".to_string()));
|
||||||
|
|
||||||
|
let error_msg = status.connection_error.clone().unwrap_or_default();
|
||||||
|
drop(status);
|
||||||
|
|
||||||
|
if was_reachable {
|
||||||
|
log::warn!("[ConnectivityMonitor] Server marked as unreachable (was reachable): {}", error_msg);
|
||||||
|
self.emit_connectivity_change(false).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start monitoring connectivity with adaptive polling
|
||||||
|
pub async fn start_monitoring(&self) {
|
||||||
|
if self.is_monitoring.swap(true, Ordering::SeqCst) {
|
||||||
|
log::info!("[ConnectivityMonitor] Already monitoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[ConnectivityMonitor] Starting connectivity monitoring");
|
||||||
|
|
||||||
|
// Perform immediate check before starting background task
|
||||||
|
// This ensures we get an accurate state right away instead of assuming offline
|
||||||
|
let is_reachable = self.check_reachability().await;
|
||||||
|
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
|
||||||
|
|
||||||
|
// Clone Arc references for the background task
|
||||||
|
let status = Arc::clone(&self.status);
|
||||||
|
let is_monitoring = Arc::clone(&self.is_monitoring);
|
||||||
|
let server_url = Arc::clone(&self.server_url);
|
||||||
|
let http_client = Arc::clone(&self.http_client);
|
||||||
|
let self_clone = Arc::new(ConnectivityMonitorHandle {
|
||||||
|
server_url,
|
||||||
|
http_client,
|
||||||
|
status,
|
||||||
|
app_handle: self.app_handle.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn background monitoring task
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while is_monitoring.load(Ordering::SeqCst) {
|
||||||
|
// Determine interval based on current reachability
|
||||||
|
let interval_ms = {
|
||||||
|
let status = self_clone.status.read().await;
|
||||||
|
if status.is_server_reachable {
|
||||||
|
AUTO_CHECK_INTERVAL_MS
|
||||||
|
} else {
|
||||||
|
RETRY_CHECK_INTERVAL_MS
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for the interval
|
||||||
|
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
|
||||||
|
|
||||||
|
// Check if still monitoring
|
||||||
|
if !is_monitoring.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform connectivity check
|
||||||
|
let _ = self_clone.check_reachability().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[ConnectivityMonitor] Stopped monitoring");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop monitoring connectivity
|
||||||
|
pub fn stop_monitoring(&self) {
|
||||||
|
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
|
||||||
|
self.is_monitoring.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit connectivity change event to frontend
|
||||||
|
async fn emit_connectivity_change(&self, is_reachable: bool) {
|
||||||
|
if let Some(app_handle) = &self.app_handle {
|
||||||
|
let event = ConnectivityChangeEvent { is_reachable };
|
||||||
|
if let Err(e) = app_handle.emit("connectivity:changed", event) {
|
||||||
|
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
|
||||||
|
} else {
|
||||||
|
log::info!("[ConnectivityMonitor] Emitted connectivity change: {}", is_reachable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit server reconnected event to frontend
|
||||||
|
async fn emit_server_reconnected(&self) {
|
||||||
|
if let Some(app_handle) = &self.app_handle {
|
||||||
|
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
|
||||||
|
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
|
||||||
|
} else {
|
||||||
|
log::info!("[ConnectivityMonitor] Emitted server reconnected event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle for the background monitoring task
|
||||||
|
struct ConnectivityMonitorHandle {
|
||||||
|
server_url: Arc<RwLock<Option<String>>>,
|
||||||
|
http_client: Arc<HttpClient>,
|
||||||
|
status: Arc<RwLock<ConnectivityStatus>>,
|
||||||
|
app_handle: Option<AppHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectivityMonitorHandle {
|
||||||
|
async fn check_reachability(&self) -> bool {
|
||||||
|
let server_url = self.server_url.read().await.clone();
|
||||||
|
|
||||||
|
if server_url.is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = server_url.unwrap();
|
||||||
|
let ping_url = format!("{}/System/Info/Public", url);
|
||||||
|
|
||||||
|
// Store previous reachability state
|
||||||
|
let was_reachable = {
|
||||||
|
let status = self.status.read().await;
|
||||||
|
status.is_server_reachable
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attempt to ping the server
|
||||||
|
let is_reachable = self.http_client.ping(&ping_url).await;
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.is_server_reachable = is_reachable;
|
||||||
|
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
status.connection_error = if is_reachable {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("Server unreachable".to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit events if reachability changed
|
||||||
|
if is_reachable != was_reachable {
|
||||||
|
self.emit_connectivity_change(is_reachable).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit reconnection event
|
||||||
|
if is_reachable && !was_reachable {
|
||||||
|
self.emit_server_reconnected().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_connectivity_change(&self, is_reachable: bool) {
|
||||||
|
if let Some(app_handle) = &self.app_handle {
|
||||||
|
let event = ConnectivityChangeEvent { is_reachable };
|
||||||
|
if let Err(e) = app_handle.emit("connectivity:changed", event) {
|
||||||
|
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn emit_server_reconnected(&self) {
|
||||||
|
if let Some(app_handle) = &self.app_handle {
|
||||||
|
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
|
||||||
|
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::jellyfin::http_client::HttpConfig;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_intervals() {
|
||||||
|
// Verify intervals match TypeScript
|
||||||
|
assert_eq!(AUTO_CHECK_INTERVAL_MS, 30000);
|
||||||
|
assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_default_status() {
|
||||||
|
let status = ConnectivityStatus::default();
|
||||||
|
// Default is now optimistic (assume online until proven otherwise)
|
||||||
|
assert!(status.is_server_reachable);
|
||||||
|
assert!(status.last_checked.is_none());
|
||||||
|
assert!(status.connection_error.is_none());
|
||||||
|
assert!(!status.is_checking);
|
||||||
|
}
|
||||||
|
}
|
||||||
820
src-tauri/src/credentials.rs
Normal file
@ -0,0 +1,820 @@
|
|||||||
|
//! Secure credential storage module
|
||||||
|
//!
|
||||||
|
//! Provides secure storage for access tokens using:
|
||||||
|
//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
|
||||||
|
//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
|
||||||
|
//!
|
||||||
|
//! The fallback is less secure as the encryption key is derived from machine
|
||||||
|
//! identifiers, but provides functionality on headless systems.
|
||||||
|
|
||||||
|
use aes_gcm::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
Aes256Gcm, Nonce,
|
||||||
|
};
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use log::{info, warn};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use hostname;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const SERVICE_NAME: &str = "com.dtourolle.jellytau";
|
||||||
|
|
||||||
|
const CREDENTIALS_FILENAME: &str = "credentials.enc";
|
||||||
|
|
||||||
|
/// Result of a credential storage operation
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CredentialResult {
|
||||||
|
/// Operation succeeded using the system keyring
|
||||||
|
Keyring,
|
||||||
|
/// Operation succeeded using encrypted file fallback
|
||||||
|
EncryptedFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error types for credential operations
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CredentialError {
|
||||||
|
/// Keyring operation failed
|
||||||
|
Keyring(String),
|
||||||
|
/// Encryption/decryption failed
|
||||||
|
Encryption(String),
|
||||||
|
/// File I/O failed
|
||||||
|
Io(String),
|
||||||
|
/// Credential not found
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CredentialError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Keyring(msg) => write!(f, "Keyring error: {}", msg),
|
||||||
|
Self::Encryption(msg) => write!(f, "Encryption error: {}", msg),
|
||||||
|
Self::Io(msg) => write!(f, "I/O error: {}", msg),
|
||||||
|
Self::NotFound => write!(f, "Credential not found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CredentialError {}
|
||||||
|
|
||||||
|
/// Credential storage manager
|
||||||
|
pub struct CredentialStore {
|
||||||
|
/// Whether we're using keyring (true) or encrypted file (false)
|
||||||
|
using_keyring: bool,
|
||||||
|
/// Path to the encrypted credentials file (fallback)
|
||||||
|
credentials_path: PathBuf,
|
||||||
|
/// Encryption key for file fallback (derived from machine ID)
|
||||||
|
encryption_key: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialStore {
|
||||||
|
/// Create a new credential store, detecting the best available backend
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let credentials_path = Self::get_credentials_path();
|
||||||
|
let encryption_key = Self::derive_encryption_key();
|
||||||
|
|
||||||
|
// Test if keyring is available by trying a dummy operation
|
||||||
|
let using_keyring = Self::test_keyring_available();
|
||||||
|
|
||||||
|
if !using_keyring {
|
||||||
|
warn!(
|
||||||
|
"[INIT] System keyring unavailable, using encrypted file fallback at {:?}. \
|
||||||
|
This is less secure than system keyring storage.",
|
||||||
|
credentials_path
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!("[INIT] Using system keyring for credential storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
using_keyring,
|
||||||
|
credentials_path,
|
||||||
|
encryption_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we're using the secure keyring backend
|
||||||
|
pub fn is_using_keyring(&self) -> bool {
|
||||||
|
self.using_keyring
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save an access token for a user
|
||||||
|
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
|
||||||
|
if self.using_keyring {
|
||||||
|
log::debug!("Saving token for user {} to keyring", user_id);
|
||||||
|
self.save_to_keyring(user_id, token)?;
|
||||||
|
Ok(CredentialResult::Keyring)
|
||||||
|
} else {
|
||||||
|
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
|
||||||
|
self.save_to_file(user_id, token)?;
|
||||||
|
log::debug!("Successfully saved token to encrypted file");
|
||||||
|
Ok(CredentialResult::EncryptedFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get an access token for a user
|
||||||
|
pub fn get_token(&self, user_id: &str) -> Result<String, CredentialError> {
|
||||||
|
if self.using_keyring {
|
||||||
|
log::debug!("Getting token for user {} from keyring", user_id);
|
||||||
|
self.get_from_keyring(user_id)
|
||||||
|
} else {
|
||||||
|
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
|
||||||
|
let result = self.get_from_file(user_id);
|
||||||
|
if result.is_ok() {
|
||||||
|
log::debug!("Successfully retrieved token from encrypted file");
|
||||||
|
} else {
|
||||||
|
log::warn!("Failed to retrieve token from encrypted file: {:?}", result);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an access token for a user
|
||||||
|
pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError> {
|
||||||
|
if self.using_keyring {
|
||||||
|
self.delete_from_keyring(user_id)
|
||||||
|
} else {
|
||||||
|
self.delete_from_file(user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keyring backend ---
|
||||||
|
|
||||||
|
fn test_keyring_available() -> bool {
|
||||||
|
// On Android, use Android Keystore via JNI
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
android_test_keystore_available()
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Linux, the keyring test can block indefinitely if Secret Service
|
||||||
|
// (gnome-keyring/kwallet) is unresponsive. Use a timeout to prevent hanging.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = Self::test_keyring_inner();
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait up to 2 seconds for keyring response
|
||||||
|
match rx.recv_timeout(Duration::from_secs(2)) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("Keyring availability check timed out after 2 seconds");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
|
||||||
|
{
|
||||||
|
Self::test_keyring_inner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn test_keyring_inner() -> bool {
|
||||||
|
// On Linux, test if secret-tool is available
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// secret-tool doesn't support --version, so we test with a search command
|
||||||
|
// that will succeed even if no items are found
|
||||||
|
match Command::new("secret-tool")
|
||||||
|
.arg("search")
|
||||||
|
.arg("service")
|
||||||
|
.arg("__nonexistent_test__")
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(_) => true, // If command runs (even with no results), secret-tool is available
|
||||||
|
Err(_) => false, // Command not found or can't execute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
||||||
|
fn test_keyring_inner() -> bool {
|
||||||
|
// On macOS/Windows, test using the keyring-rs library
|
||||||
|
let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
|
||||||
|
match entry {
|
||||||
|
Ok(e) => {
|
||||||
|
// Try to get (will fail with NotFound, which is fine)
|
||||||
|
// If it fails with a different error, keyring is not available
|
||||||
|
match e.get_password() {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(keyring::Error::NoEntry) => true,
|
||||||
|
Err(keyring::Error::NoStorageAccess(_)) => false,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_to_keyring(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
|
||||||
|
// On Android, use Android Keystore via JNI
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
android_keystore::save_token(user_id, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
|
||||||
|
// See Technical Debt section in README.md for details
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
let mut child = Command::new("secret-tool")
|
||||||
|
.arg("store")
|
||||||
|
.arg("--label")
|
||||||
|
.arg(format!("{}@{}", key, SERVICE_NAME))
|
||||||
|
.arg("service")
|
||||||
|
.arg(SERVICE_NAME)
|
||||||
|
.arg("username")
|
||||||
|
.arg(&key)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin.write_all(token.as_bytes())
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child.wait()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
|
||||||
|
{
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
let entry = keyring::Entry::new(SERVICE_NAME, &key)
|
||||||
|
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
|
||||||
|
entry
|
||||||
|
.set_password(token)
|
||||||
|
.map_err(|e| CredentialError::Keyring(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_from_keyring(&self, user_id: &str) -> Result<String, CredentialError> {
|
||||||
|
// On Android, use Android Keystore via JNI
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
android_keystore::get_token(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
|
||||||
|
// See Technical Debt section in README.md for details
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
|
||||||
|
|
||||||
|
let output = Command::new("secret-tool")
|
||||||
|
.arg("lookup")
|
||||||
|
.arg("service")
|
||||||
|
.arg(SERVICE_NAME)
|
||||||
|
.arg("username")
|
||||||
|
.arg(&key)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
|
||||||
|
let token = String::from_utf8(output.stdout)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
Ok(token)
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
|
||||||
|
Err(CredentialError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
|
||||||
|
{
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
let entry = keyring::Entry::new(SERVICE_NAME, &key)
|
||||||
|
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
|
||||||
|
entry.get_password().map_err(|e| match e {
|
||||||
|
keyring::Error::NoEntry => CredentialError::NotFound,
|
||||||
|
_ => CredentialError::Keyring(e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError> {
|
||||||
|
// On Android, use Android Keystore via JNI
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
android_keystore::delete_token(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
|
||||||
|
// See Technical Debt section in README.md for details
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
let status = Command::new("secret-tool")
|
||||||
|
.arg("clear")
|
||||||
|
.arg("service")
|
||||||
|
.arg(SERVICE_NAME)
|
||||||
|
.arg("username")
|
||||||
|
.arg(&key)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
|
||||||
|
|
||||||
|
// secret-tool clear returns success even if entry doesn't exist
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
|
||||||
|
{
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
let entry = keyring::Entry::new(SERVICE_NAME, &key)
|
||||||
|
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
|
||||||
|
Err(e) => Err(CredentialError::Keyring(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Encrypted file backend ---
|
||||||
|
|
||||||
|
fn get_credentials_path() -> PathBuf {
|
||||||
|
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
|
||||||
|
proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
|
||||||
|
} else {
|
||||||
|
PathBuf::from(CREDENTIALS_FILENAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_encryption_key() -> [u8; 32] {
|
||||||
|
// Derive a key from machine-specific identifiers
|
||||||
|
// This is less secure than a true keyring but provides some protection
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
|
||||||
|
// Use hostname on Linux (where it's available and stable)
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Ok(hostname) = hostname::get() {
|
||||||
|
hasher.update(hostname.to_string_lossy().as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Android, read device properties from the filesystem
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
// Try to read Android build properties from /system/build.prop
|
||||||
|
let build_prop_paths = [
|
||||||
|
"/system/build.prop",
|
||||||
|
"/vendor/build.prop",
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in &build_prop_paths {
|
||||||
|
if let Ok(content) = fs::read_to_string(path) {
|
||||||
|
// Extract key properties for device fingerprint
|
||||||
|
for line in content.lines() {
|
||||||
|
if line.starts_with("ro.build.fingerprint=")
|
||||||
|
|| line.starts_with("ro.serialno=")
|
||||||
|
|| line.starts_with("ro.build.id=")
|
||||||
|
|| line.starts_with("ro.product.model=") {
|
||||||
|
hasher.update(line.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also use the app data directory path as it's device/install-specific
|
||||||
|
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
|
||||||
|
hasher.update(proj_dirs.data_dir().to_string_lossy().as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a static salt (app-specific)
|
||||||
|
hasher.update(b"jellytau-credential-encryption-v1");
|
||||||
|
|
||||||
|
// Add username for additional entropy (if available)
|
||||||
|
if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
|
||||||
|
hasher.update(user.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
|
||||||
|
if !self.credentials_path.exists() {
|
||||||
|
return Ok(serde_json::json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted_data =
|
||||||
|
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
if encrypted_data.is_empty() {
|
||||||
|
return Ok(serde_json::json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypted = self.decrypt(&encrypted_data)?;
|
||||||
|
serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = self.credentials_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
let encrypted = self.encrypt(&json)?;
|
||||||
|
|
||||||
|
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
|
||||||
|
let cipher =
|
||||||
|
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
// Generate a random nonce
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, plaintext.as_bytes())
|
||||||
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
// Prepend nonce to ciphertext and encode as base64
|
||||||
|
let mut combined = nonce_bytes.to_vec();
|
||||||
|
combined.extend(ciphertext);
|
||||||
|
|
||||||
|
Ok(BASE64.encode(&combined))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
|
||||||
|
let combined = BASE64
|
||||||
|
.decode(encrypted)
|
||||||
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
if combined.len() < 12 {
|
||||||
|
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||||
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
|
||||||
|
let cipher =
|
||||||
|
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
|
||||||
|
let mut data = self.load_credentials_file()?;
|
||||||
|
data[user_id] = serde_json::json!(token);
|
||||||
|
self.save_credentials_file(&data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_from_file(&self, user_id: &str) -> Result<String, CredentialError> {
|
||||||
|
let data = self.load_credentials_file()?;
|
||||||
|
data.get(user_id)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or(CredentialError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError> {
|
||||||
|
let mut data = self.load_credentials_file()?;
|
||||||
|
if let Some(obj) = data.as_object_mut() {
|
||||||
|
obj.remove(user_id);
|
||||||
|
}
|
||||||
|
self.save_credentials_file(&data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CredentialStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Android Keystore integration via JNI ---
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android_keystore {
|
||||||
|
use super::*;
|
||||||
|
use jni::objects::{JClass, JObject, JString, JValue};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Cached reference to the SecureStorage class
|
||||||
|
static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
|
||||||
|
|
||||||
|
/// Initialize the SecureStorage singleton from Android context
|
||||||
|
pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
|
||||||
|
log::info!("Initializing Android SecureStorage...");
|
||||||
|
|
||||||
|
// Get the ClassLoader from the Context
|
||||||
|
let class_loader = env
|
||||||
|
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
|
||||||
|
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||||
|
|
||||||
|
// Load the SecureStorage class
|
||||||
|
let class_name = env
|
||||||
|
.new_string(SECURE_STORAGE_CLASS_NAME.replace('/', "."))
|
||||||
|
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||||
|
|
||||||
|
let storage_class_obj = env
|
||||||
|
.call_method(
|
||||||
|
&class_loader,
|
||||||
|
"loadClass",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||||
|
&[JValue::Object(&class_name.into())],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
|
||||||
|
|
||||||
|
let storage_class = JClass::from(storage_class_obj);
|
||||||
|
|
||||||
|
// Call SecureStorage.initialize(context)
|
||||||
|
env.call_static_method(
|
||||||
|
&storage_class,
|
||||||
|
"initialize",
|
||||||
|
"(Landroid/content/Context;)V",
|
||||||
|
&[JValue::Object(context)],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to initialize SecureStorage: {}", e))?;
|
||||||
|
|
||||||
|
// Cache the class name for future use
|
||||||
|
let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
|
||||||
|
|
||||||
|
log::info!("Android SecureStorage initialized successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test if Android Keystore is available
|
||||||
|
pub fn test_keystore_available() -> bool {
|
||||||
|
// Get JNI environment
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
||||||
|
|
||||||
|
let vm = match vm {
|
||||||
|
Ok(vm) => vm,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to get JavaVM for keystore test: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut env = match vm.attach_current_thread() {
|
||||||
|
Ok(env) => env,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to attach thread for keystore test: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get the SecureStorage instance
|
||||||
|
match get_secure_storage_instance(&mut env) {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!("Android Keystore available via SecureStorage");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Android Keystore not available: {}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the SecureStorage singleton instance
|
||||||
|
fn get_secure_storage_instance<'a>(env: &mut JNIEnv<'a>) -> Result<JObject<'a>, String> {
|
||||||
|
let class_name = SECURE_STORAGE_CLASS
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| "SecureStorage not initialized".to_string())?;
|
||||||
|
|
||||||
|
// Get the Android context
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
let context = unsafe { JObject::from_raw(ctx.context().cast()) };
|
||||||
|
|
||||||
|
// Get the ClassLoader from the Context
|
||||||
|
let class_loader = env
|
||||||
|
.call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
|
||||||
|
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||||
|
|
||||||
|
// Load the SecureStorage class using the app's classloader
|
||||||
|
let class_name_jstring = env
|
||||||
|
.new_string(class_name.replace('/', "."))
|
||||||
|
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||||
|
|
||||||
|
let storage_class_obj = env
|
||||||
|
.call_method(
|
||||||
|
&class_loader,
|
||||||
|
"loadClass",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||||
|
&[JValue::Object(&class_name_jstring.into())],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
|
||||||
|
|
||||||
|
let storage_class = JClass::from(storage_class_obj);
|
||||||
|
|
||||||
|
let instance = env
|
||||||
|
.call_static_method(
|
||||||
|
&storage_class,
|
||||||
|
"getInstance",
|
||||||
|
"()Lcom/dtourolle/jellytau/security/SecureStorage;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to get SecureStorage instance: {}", e))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| format!("Failed to convert to object: {}", e))?;
|
||||||
|
|
||||||
|
Ok(instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a token using Android Keystore
|
||||||
|
pub fn save_token(user_id: &str, token: &str) -> Result<(), CredentialError> {
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
|
||||||
|
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||||
|
|
||||||
|
let instance = get_secure_storage_instance(&mut env)
|
||||||
|
.map_err(|e| CredentialError::Keyring(e))?;
|
||||||
|
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
let key_jstring = env
|
||||||
|
.new_string(&key)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
|
||||||
|
let token_jstring = env
|
||||||
|
.new_string(token)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
|
||||||
|
|
||||||
|
let result = env
|
||||||
|
.call_method(
|
||||||
|
instance,
|
||||||
|
"saveToken",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)Z",
|
||||||
|
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
|
||||||
|
)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
|
||||||
|
.z()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
|
||||||
|
|
||||||
|
if result {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CredentialError::Keyring("saveToken returned false".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a token from Android Keystore
|
||||||
|
pub fn get_token(user_id: &str) -> Result<String, CredentialError> {
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
|
||||||
|
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||||
|
|
||||||
|
let instance = get_secure_storage_instance(&mut env)
|
||||||
|
.map_err(|e| CredentialError::Keyring(e))?;
|
||||||
|
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
let key_jstring = env
|
||||||
|
.new_string(&key)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
|
||||||
|
|
||||||
|
let result = env
|
||||||
|
.call_method(
|
||||||
|
instance,
|
||||||
|
"getToken",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/String;",
|
||||||
|
&[JValue::Object(&key_jstring.into())],
|
||||||
|
)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to call getToken: {}", e)))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to get object result: {}", e)))?;
|
||||||
|
|
||||||
|
if result.is_null() {
|
||||||
|
return Err(CredentialError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_jstring = JString::from(result);
|
||||||
|
let token: String = env
|
||||||
|
.get_string(&token_jstring)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to get string: {}", e)))?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a token from Android Keystore
|
||||||
|
pub fn delete_token(user_id: &str) -> Result<(), CredentialError> {
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
|
||||||
|
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||||
|
|
||||||
|
let instance = get_secure_storage_instance(&mut env)
|
||||||
|
.map_err(|e| CredentialError::Keyring(e))?;
|
||||||
|
|
||||||
|
let key = format!("access_token:{}", user_id);
|
||||||
|
let key_jstring = env
|
||||||
|
.new_string(&key)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
|
||||||
|
|
||||||
|
let result = env
|
||||||
|
.call_method(
|
||||||
|
instance,
|
||||||
|
"deleteToken",
|
||||||
|
"(Ljava/lang/String;)Z",
|
||||||
|
&[JValue::Object(&key_jstring.into())],
|
||||||
|
)
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
|
||||||
|
.z()
|
||||||
|
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
|
||||||
|
|
||||||
|
if result {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export Android keystore functions at the module level for easier access
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encryption_roundtrip() {
|
||||||
|
let store = CredentialStore::new();
|
||||||
|
let plaintext = "test-access-token-12345";
|
||||||
|
|
||||||
|
let encrypted = store.encrypt(plaintext).unwrap();
|
||||||
|
let decrypted = store.decrypt(&encrypted).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(plaintext, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_encryption_key_is_deterministic() {
|
||||||
|
let key1 = CredentialStore::derive_encryption_key();
|
||||||
|
let key2 = CredentialStore::derive_encryption_key();
|
||||||
|
assert_eq!(key1, key2);
|
||||||
|
}
|
||||||
|
}
|
||||||
326
src-tauri/src/download/cache.rs
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
//! Smart caching engine for predictive downloads
|
||||||
|
|
||||||
|
use log::{debug, info};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// Smart caching configuration
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CacheConfig {
|
||||||
|
/// Enable queue pre-caching
|
||||||
|
pub queue_precache_enabled: bool,
|
||||||
|
/// Number of tracks to pre-cache from queue
|
||||||
|
pub queue_precache_count: usize,
|
||||||
|
/// Enable album affinity detection
|
||||||
|
pub album_affinity_enabled: bool,
|
||||||
|
/// Threshold for album affinity (tracks played before caching)
|
||||||
|
pub album_affinity_threshold: usize,
|
||||||
|
/// Storage limit in bytes (0 = unlimited)
|
||||||
|
pub storage_limit: u64,
|
||||||
|
/// Only cache on WiFi
|
||||||
|
pub wifi_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CacheConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
queue_precache_enabled: true,
|
||||||
|
queue_precache_count: 3, // Preload next 3 tracks by default
|
||||||
|
album_affinity_enabled: true,
|
||||||
|
album_affinity_threshold: 3,
|
||||||
|
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
||||||
|
wifi_only: false, // Allow preloading on any connection by default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Smart caching engine
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SmartCache {
|
||||||
|
config: Arc<Mutex<CacheConfig>>,
|
||||||
|
/// Track recently played items per album
|
||||||
|
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmartCache {
|
||||||
|
pub fn new(config: CacheConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config: Arc::new(Mutex::new(config)),
|
||||||
|
album_play_history: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update configuration
|
||||||
|
pub fn update_config(&self, config: CacheConfig) {
|
||||||
|
if let Ok(mut cfg) = self.config.lock() {
|
||||||
|
*cfg = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if should pre-cache queue items
|
||||||
|
pub fn should_precache_queue(&self) -> bool {
|
||||||
|
self.config
|
||||||
|
.lock()
|
||||||
|
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get number of queue items to pre-cache
|
||||||
|
pub fn queue_precache_count(&self) -> usize {
|
||||||
|
self.config
|
||||||
|
.lock()
|
||||||
|
.map(|cfg| cfg.queue_precache_count)
|
||||||
|
.unwrap_or(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track that an item was played
|
||||||
|
pub fn track_play(&self, item_id: &str, album_id: Option<&str>) {
|
||||||
|
if let Some(album) = album_id {
|
||||||
|
if let Ok(mut history) = self.album_play_history.lock() {
|
||||||
|
let plays = history.entry(album.to_string()).or_insert_with(Vec::new);
|
||||||
|
if !plays.contains(&item_id.to_string()) {
|
||||||
|
plays.push(item_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if album affinity threshold reached for caching
|
||||||
|
pub fn should_cache_album(&self, album_id: &str) -> Option<bool> {
|
||||||
|
let config = self.config.lock().ok()?;
|
||||||
|
if !config.album_affinity_enabled {
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let history = self.album_play_history.lock().ok()?;
|
||||||
|
let play_count = history.get(album_id).map(|v| v.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
Some(play_count >= config.album_affinity_threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get configuration
|
||||||
|
pub fn get_config(&self) -> Option<CacheConfig> {
|
||||||
|
self.config.lock().ok().map(|cfg| cfg.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all tracked albums with their play counts
|
||||||
|
/// Returns Vec<(album_id, unique_tracks_played)>
|
||||||
|
pub fn get_album_play_history(&self) -> Vec<(String, usize)> {
|
||||||
|
self.album_play_history
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.map(|history| {
|
||||||
|
history
|
||||||
|
.iter()
|
||||||
|
.map(|(album_id, tracks)| (album_id.clone(), tracks.len()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Async versions for DatabaseService =============
|
||||||
|
|
||||||
|
/// Get total download size for a user (async version)
|
||||||
|
pub async fn get_total_download_size_async<S: DatabaseService>(
|
||||||
|
&self,
|
||||||
|
db_service: &Arc<S>,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT COALESCE(SUM(file_size), 0) FROM downloads
|
||||||
|
WHERE user_id = ? AND status = 'completed'",
|
||||||
|
vec![QueryParam::String(user_id.to_string())],
|
||||||
|
);
|
||||||
|
|
||||||
|
let size: i64 = db_service
|
||||||
|
.query_one(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(size as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if storage limit allows download (async version)
|
||||||
|
pub async fn can_download_async<S: DatabaseService>(
|
||||||
|
&self,
|
||||||
|
db_service: &Arc<S>,
|
||||||
|
user_id: &str,
|
||||||
|
new_size: u64,
|
||||||
|
) -> bool {
|
||||||
|
// Clone config to avoid holding lock across await
|
||||||
|
let storage_limit = {
|
||||||
|
match self.config.lock() {
|
||||||
|
Ok(cfg) => cfg.storage_limit,
|
||||||
|
Err(_) => return true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if storage_limit == 0 {
|
||||||
|
return true; // Unlimited
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_size = self
|
||||||
|
.get_total_download_size_async(db_service, user_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
current_size + new_size <= storage_limit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict least recently used items to make space (async version)
|
||||||
|
pub async fn evict_lru_async<S: DatabaseService>(
|
||||||
|
&self,
|
||||||
|
db_service: &Arc<S>,
|
||||||
|
user_id: &str,
|
||||||
|
space_needed: u64,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
let current_size = self
|
||||||
|
.get_total_download_size_async(db_service, user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Get limit without holding lock across await
|
||||||
|
let limit = {
|
||||||
|
let config = self.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
config.storage_limit
|
||||||
|
};
|
||||||
|
|
||||||
|
if limit == 0 || current_size + space_needed <= limit {
|
||||||
|
return Ok(0); // No eviction needed
|
||||||
|
}
|
||||||
|
|
||||||
|
let to_free = (current_size + space_needed) - limit;
|
||||||
|
let mut freed: u64 = 0;
|
||||||
|
|
||||||
|
// Get downloads ordered by last access (oldest first)
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT id, file_size, file_path FROM downloads
|
||||||
|
WHERE user_id = ? AND status = 'completed'
|
||||||
|
ORDER BY completed_at ASC",
|
||||||
|
vec![QueryParam::String(user_id.to_string())],
|
||||||
|
);
|
||||||
|
|
||||||
|
let downloads: Vec<(i64, i64, String)> = db_service
|
||||||
|
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for (id, size, file_path) in downloads {
|
||||||
|
if freed >= to_free {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
let _ = std::fs::remove_file(&file_path);
|
||||||
|
debug!("[SmartCache] Evicted: {} ({} bytes)", file_path, size);
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
let delete_query = Query::with_params(
|
||||||
|
"DELETE FROM downloads WHERE id = ?",
|
||||||
|
vec![QueryParam::Int64(id)],
|
||||||
|
);
|
||||||
|
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
freed += size as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("[SmartCache] Freed {} bytes ({} needed)", freed, to_free);
|
||||||
|
Ok(freed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = CacheConfig::default();
|
||||||
|
assert_eq!(config.queue_precache_count, 3);
|
||||||
|
assert_eq!(config.album_affinity_threshold, 3);
|
||||||
|
assert!(!config.wifi_only); // wifi_only is false by default for easier preloading
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_album_affinity_tracking() {
|
||||||
|
let cache = SmartCache::new(CacheConfig::default());
|
||||||
|
|
||||||
|
// Track plays from same album
|
||||||
|
cache.track_play("track1", Some("album1"));
|
||||||
|
cache.track_play("track2", Some("album1"));
|
||||||
|
|
||||||
|
// Below threshold
|
||||||
|
assert!(!cache.should_cache_album("album1").unwrap_or(false));
|
||||||
|
|
||||||
|
cache.track_play("track3", Some("album1"));
|
||||||
|
|
||||||
|
// At threshold - should cache
|
||||||
|
assert!(cache.should_cache_album("album1").unwrap_or(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_precache_config() {
|
||||||
|
let mut config = CacheConfig::default();
|
||||||
|
config.queue_precache_enabled = false;
|
||||||
|
|
||||||
|
let cache = SmartCache::new(config);
|
||||||
|
assert!(!cache.should_precache_queue());
|
||||||
|
|
||||||
|
let mut new_config = CacheConfig::default();
|
||||||
|
new_config.wifi_only = false;
|
||||||
|
cache.update_config(new_config);
|
||||||
|
|
||||||
|
assert!(cache.should_precache_queue());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_storage_limit_check() {
|
||||||
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
file_size INTEGER
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let conn_arc = Arc::new(Mutex::new(conn));
|
||||||
|
let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
|
||||||
|
|
||||||
|
let config = CacheConfig {
|
||||||
|
storage_limit: 1000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cache = SmartCache::new(config);
|
||||||
|
|
||||||
|
// Empty - can download
|
||||||
|
assert!(cache.can_download_async(&db_service, "user1", 500).await);
|
||||||
|
|
||||||
|
// Add some downloads
|
||||||
|
{
|
||||||
|
let conn_guard = conn_arc.lock().unwrap();
|
||||||
|
conn_guard.execute(
|
||||||
|
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total would be 1100 > 1000
|
||||||
|
assert!(!cache.can_download_async(&db_service, "user1", 500).await);
|
||||||
|
|
||||||
|
// Smaller size fits
|
||||||
|
assert!(cache.can_download_async(&db_service, "user1", 300).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src-tauri/src/download/events.rs
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
//! Download events for progress tracking and status updates
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Events emitted during download operations
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum DownloadEvent {
|
||||||
|
/// Download has been queued
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Queued {
|
||||||
|
download_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
},
|
||||||
|
/// Download has started
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Started {
|
||||||
|
download_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
},
|
||||||
|
/// Download progress update
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Progress {
|
||||||
|
download_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
bytes_downloaded: i64,
|
||||||
|
total_bytes: Option<i64>,
|
||||||
|
progress: f64, // 0.0 to 1.0
|
||||||
|
},
|
||||||
|
/// Download completed successfully
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Completed {
|
||||||
|
download_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
file_path: String,
|
||||||
|
},
|
||||||
|
/// Download failed with error
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Failed {
|
||||||
|
download_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
|
/// Download paused
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Paused {
|
||||||
|
download_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
},
|
||||||
|
/// Download cancelled
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Cancelled {
|
||||||
|
download_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_download_event_serialization_roundtrip() {
|
||||||
|
let event = DownloadEvent::Progress {
|
||||||
|
download_id: 1,
|
||||||
|
item_id: "test123".to_string(),
|
||||||
|
bytes_downloaded: 1024,
|
||||||
|
total_bytes: Some(2048),
|
||||||
|
progress: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
match deserialized {
|
||||||
|
DownloadEvent::Progress {
|
||||||
|
download_id,
|
||||||
|
item_id,
|
||||||
|
progress,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(download_id, 1);
|
||||||
|
assert_eq!(item_id, "test123");
|
||||||
|
assert_eq!(progress, 0.5);
|
||||||
|
}
|
||||||
|
_ => panic!("Wrong variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_download_event_completed() {
|
||||||
|
let event = DownloadEvent::Completed {
|
||||||
|
download_id: 42,
|
||||||
|
item_id: "song456".to_string(),
|
||||||
|
file_path: "/path/to/file.mp3".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(json.contains("\"type\":\"completed\""));
|
||||||
|
// Verify camelCase field names
|
||||||
|
assert!(json.contains("\"downloadId\":42"), "Expected downloadId (camelCase), got: {}", json);
|
||||||
|
assert!(json.contains("\"itemId\":\"song456\""), "Expected itemId (camelCase), got: {}", json);
|
||||||
|
assert!(json.contains("\"filePath\":"), "Expected filePath (camelCase), got: {}", json);
|
||||||
|
|
||||||
|
// Verify roundtrip
|
||||||
|
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
||||||
|
match deserialized {
|
||||||
|
DownloadEvent::Completed { file_path, .. } => {
|
||||||
|
assert_eq!(file_path, "/path/to/file.mp3");
|
||||||
|
}
|
||||||
|
_ => panic!("Wrong variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_download_event_failed() {
|
||||||
|
let event = DownloadEvent::Failed {
|
||||||
|
download_id: 10,
|
||||||
|
item_id: "failed_item".to_string(),
|
||||||
|
error: "Network timeout".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
|
assert!(json.contains("\"type\":\"failed\""));
|
||||||
|
|
||||||
|
// Verify roundtrip
|
||||||
|
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
|
||||||
|
match deserialized {
|
||||||
|
DownloadEvent::Failed { error, .. } => {
|
||||||
|
assert_eq!(error, "Network timeout");
|
||||||
|
}
|
||||||
|
_ => panic!("Wrong variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
210
src-tauri/src/download/mod.rs
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
//! Download manager for offline media support
|
||||||
|
//!
|
||||||
|
//! This module handles downloading media from Jellyfin servers with:
|
||||||
|
//! - Priority-based queue management
|
||||||
|
//! - Progress tracking and event emission
|
||||||
|
//! - Retry logic with exponential backoff
|
||||||
|
//! - Resume support via HTTP Range requests
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
|
pub mod events;
|
||||||
|
pub mod worker;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
pub use worker::DownloadWorker;
|
||||||
|
|
||||||
|
/// Download manager coordinating downloads across workers
|
||||||
|
pub struct DownloadManager {
|
||||||
|
/// Maximum concurrent downloads
|
||||||
|
max_concurrent: usize,
|
||||||
|
/// Currently active download IDs
|
||||||
|
active_downloads: Arc<Mutex<HashSet<i64>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadManager {
|
||||||
|
/// Create a new download manager
|
||||||
|
pub fn new(_media_dir: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
max_concurrent: 3,
|
||||||
|
active_downloads: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the maximum concurrent downloads
|
||||||
|
pub fn max_concurrent(&self) -> usize {
|
||||||
|
self.max_concurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the maximum concurrent downloads
|
||||||
|
pub fn set_max_concurrent(&mut self, max: usize) {
|
||||||
|
self.max_concurrent = max.max(1); // At least 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a new download can be started based on concurrent limit
|
||||||
|
pub fn can_start_download(&self) -> bool {
|
||||||
|
let active = self.active_downloads.lock().unwrap();
|
||||||
|
active.len() < self.max_concurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of currently active downloads
|
||||||
|
pub fn active_count(&self) -> usize {
|
||||||
|
self.active_downloads.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a download as active
|
||||||
|
pub fn register_download(&self, download_id: i64) -> bool {
|
||||||
|
let mut active = self.active_downloads.lock().unwrap();
|
||||||
|
if active.len() >= self.max_concurrent {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
active.insert(download_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unregister a download when it completes or fails
|
||||||
|
pub fn unregister_download(&self, download_id: i64) {
|
||||||
|
let mut active = self.active_downloads.lock().unwrap();
|
||||||
|
active.remove(&download_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a clone of the active downloads set (for internal use)
|
||||||
|
pub fn get_active_downloads(&self) -> Arc<Mutex<HashSet<i64>>> {
|
||||||
|
self.active_downloads.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about a download
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DownloadInfo {
|
||||||
|
pub id: i64,
|
||||||
|
pub item_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub file_size: Option<i64>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub progress: f64,
|
||||||
|
pub bytes_downloaded: i64,
|
||||||
|
pub queued_at: String,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub retry_count: i32,
|
||||||
|
pub priority: i32,
|
||||||
|
// Item metadata for display (audio)
|
||||||
|
pub item_name: Option<String>,
|
||||||
|
pub artist_name: Option<String>,
|
||||||
|
pub album_name: Option<String>,
|
||||||
|
// Video-specific metadata
|
||||||
|
pub series_name: Option<String>,
|
||||||
|
pub season_name: Option<String>,
|
||||||
|
pub episode_number: Option<i32>,
|
||||||
|
pub season_number: Option<i32>,
|
||||||
|
pub quality_preset: Option<String>,
|
||||||
|
pub media_type: String,
|
||||||
|
// Download source tracking
|
||||||
|
pub download_source: String, // 'user' or 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download task for workers
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DownloadTask {
|
||||||
|
pub url: String,
|
||||||
|
pub target_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::player::MediaType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_download_manager_set_max_concurrent() {
|
||||||
|
let media_dir = PathBuf::from("/tmp/jellytau/media");
|
||||||
|
let mut manager = DownloadManager::new(media_dir);
|
||||||
|
|
||||||
|
manager.set_max_concurrent(5);
|
||||||
|
assert_eq!(manager.max_concurrent(), 5);
|
||||||
|
|
||||||
|
// Should clamp to minimum of 1
|
||||||
|
manager.set_max_concurrent(0);
|
||||||
|
assert_eq!(manager.max_concurrent(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_download_info_serialization() {
|
||||||
|
let info = DownloadInfo {
|
||||||
|
id: 1,
|
||||||
|
item_id: "test123".to_string(),
|
||||||
|
user_id: "user1".to_string(),
|
||||||
|
file_path: "/path/to/file.mp3".to_string(),
|
||||||
|
file_size: Some(1024000),
|
||||||
|
mime_type: Some("audio/mpeg".to_string()),
|
||||||
|
status: "downloading".to_string(),
|
||||||
|
progress: 0.5,
|
||||||
|
bytes_downloaded: 512000,
|
||||||
|
queued_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
started_at: Some("2024-01-01T00:01:00Z".to_string()),
|
||||||
|
completed_at: None,
|
||||||
|
error_message: None,
|
||||||
|
retry_count: 0,
|
||||||
|
priority: 0,
|
||||||
|
item_name: Some("Test Song".to_string()),
|
||||||
|
artist_name: Some("Test Artist".to_string()),
|
||||||
|
album_name: Some("Test Album".to_string()),
|
||||||
|
series_name: None,
|
||||||
|
season_name: None,
|
||||||
|
episode_number: None,
|
||||||
|
season_number: None,
|
||||||
|
quality_preset: Some("original".to_string()),
|
||||||
|
media_type: "audio".to_string(),
|
||||||
|
download_source: "user".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&info).unwrap();
|
||||||
|
assert!(json.contains("\"status\":\"downloading\""));
|
||||||
|
assert!(json.contains("\"progress\":0.5"));
|
||||||
|
assert!(json.contains("\"itemName\":\"Test Song\""));
|
||||||
|
assert!(json.contains("\"mediaType\":\"audio\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_video_download_info_serialization() {
|
||||||
|
let info = DownloadInfo {
|
||||||
|
id: 2,
|
||||||
|
item_id: "episode123".to_string(),
|
||||||
|
user_id: "user1".to_string(),
|
||||||
|
file_path: "/path/to/ShowName/S01E01_Title.mp4".to_string(),
|
||||||
|
file_size: Some(1024000000),
|
||||||
|
mime_type: Some("video/mp4".to_string()),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
progress: 1.0,
|
||||||
|
bytes_downloaded: 1024000000,
|
||||||
|
queued_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
started_at: Some("2024-01-01T00:01:00Z".to_string()),
|
||||||
|
completed_at: Some("2024-01-01T01:00:00Z".to_string()),
|
||||||
|
error_message: None,
|
||||||
|
retry_count: 0,
|
||||||
|
priority: 100,
|
||||||
|
item_name: Some("Episode Title".to_string()),
|
||||||
|
artist_name: None,
|
||||||
|
album_name: None,
|
||||||
|
series_name: Some("Show Name".to_string()),
|
||||||
|
season_name: Some("Season 1".to_string()),
|
||||||
|
episode_number: Some(1),
|
||||||
|
season_number: Some(1),
|
||||||
|
quality_preset: Some("high".to_string()),
|
||||||
|
media_type: "video".to_string(),
|
||||||
|
download_source: "auto".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&info).unwrap();
|
||||||
|
assert!(json.contains("\"mediaType\":\"video\""));
|
||||||
|
assert!(json.contains("\"seriesName\":\"Show Name\""));
|
||||||
|
assert!(json.contains("\"episodeNumber\":1"));
|
||||||
|
assert!(json.contains("\"qualityPreset\":\"high\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
214
src-tauri/src/download/worker.rs
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
//! Download worker for HTTP streaming with progress tracking and retry logic
|
||||||
|
|
||||||
|
use log::warn;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
use super::DownloadTask;
|
||||||
|
|
||||||
|
/// Download worker that handles individual download tasks
|
||||||
|
pub struct DownloadWorker {
|
||||||
|
/// HTTP client for downloads
|
||||||
|
client: reqwest::Client,
|
||||||
|
/// Maximum retry attempts
|
||||||
|
max_retries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadWorker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
max_retries: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a file with retry logic and progress tracking
|
||||||
|
pub async fn download(
|
||||||
|
&self,
|
||||||
|
task: &DownloadTask,
|
||||||
|
) -> Result<DownloadResult, DownloadError> {
|
||||||
|
let mut retries = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match self.try_download(task).await {
|
||||||
|
Ok(result) => return Ok(result),
|
||||||
|
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||||
|
retries += 1;
|
||||||
|
let delay = Self::exponential_backoff(retries);
|
||||||
|
warn!(
|
||||||
|
"Download failed (attempt {}/{}), retrying in {:?}: {}",
|
||||||
|
retries, self.max_retries, delay, e
|
||||||
|
);
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt a single download
|
||||||
|
async fn try_download(&self, task: &DownloadTask) -> Result<DownloadResult, DownloadError> {
|
||||||
|
// Create parent directories
|
||||||
|
if let Some(parent) = task.target_path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for partial download
|
||||||
|
let temp_path = task.target_path.with_extension("part");
|
||||||
|
let existing_bytes = if temp_path.exists() {
|
||||||
|
fs::metadata(&temp_path)
|
||||||
|
.await
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build HTTP request with Range header for resume support
|
||||||
|
let mut request = self.client.get(&task.url);
|
||||||
|
if existing_bytes > 0 {
|
||||||
|
request = request.header("Range", format!("bytes={}-", existing_bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
if !response.status().is_success() && response.status().as_u16() != 206 {
|
||||||
|
return Err(DownloadError::Http(response.status().as_u16()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get content length
|
||||||
|
let _total_bytes = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_LENGTH)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
.map(|len| if existing_bytes > 0 { len + existing_bytes } else { len });
|
||||||
|
|
||||||
|
// Open file for appending
|
||||||
|
let mut file = if existing_bytes > 0 {
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&temp_path)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
fs::File::create(&temp_path).await
|
||||||
|
}
|
||||||
|
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||||
|
|
||||||
|
// Stream download with progress tracking
|
||||||
|
let mut downloaded = existing_bytes;
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
let mut last_progress_emit = std::time::Instant::now();
|
||||||
|
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
file.write_all(&chunk)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||||
|
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
|
||||||
|
// Emit progress every 500ms or every MB
|
||||||
|
if last_progress_emit.elapsed() > Duration::from_millis(500)
|
||||||
|
|| downloaded % (1024 * 1024) == 0
|
||||||
|
{
|
||||||
|
last_progress_emit = std::time::Instant::now();
|
||||||
|
// Progress events will be emitted by the manager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.sync_all()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||||
|
|
||||||
|
// Move from .part to final location
|
||||||
|
fs::rename(&temp_path, &task.target_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(DownloadResult {
|
||||||
|
bytes_downloaded: downloaded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate exponential backoff delay
|
||||||
|
fn exponential_backoff(retry_count: u32) -> Duration {
|
||||||
|
let base_delay = 5; // 5 seconds
|
||||||
|
let delay_secs = base_delay * 3u64.pow(retry_count - 1); // 5s, 15s, 45s
|
||||||
|
Duration::from_secs(delay_secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful download
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DownloadResult {
|
||||||
|
pub bytes_downloaded: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download error types
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DownloadError {
|
||||||
|
Network(String),
|
||||||
|
Http(u16),
|
||||||
|
FileSystem(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadError {
|
||||||
|
/// Check if this error is retryable
|
||||||
|
fn is_retryable(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
DownloadError::Network(_) => true,
|
||||||
|
DownloadError::Http(status) => *status >= 500, // Retry server errors
|
||||||
|
DownloadError::FileSystem(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DownloadError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||||
|
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
|
||||||
|
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for DownloadError {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exponential_backoff() {
|
||||||
|
assert_eq!(DownloadWorker::exponential_backoff(1), Duration::from_secs(5));
|
||||||
|
assert_eq!(DownloadWorker::exponential_backoff(2), Duration::from_secs(15));
|
||||||
|
assert_eq!(DownloadWorker::exponential_backoff(3), Duration::from_secs(45));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_retryable() {
|
||||||
|
assert!(DownloadError::Network("timeout".to_string()).is_retryable());
|
||||||
|
assert!(DownloadError::Http(500).is_retryable());
|
||||||
|
assert!(DownloadError::Http(503).is_retryable());
|
||||||
|
assert!(!DownloadError::Http(404).is_retryable());
|
||||||
|
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
|
||||||
|
}
|
||||||
|
}
|
||||||
451
src-tauri/src/jellyfin/client.rs
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
use log::{debug, error, info};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
const APP_NAME: &str = "JellyTau";
|
||||||
|
const APP_VERSION: &str = "0.1.0";
|
||||||
|
|
||||||
|
/// Jellyfin API client for playback reporting
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JellyfinClient {
|
||||||
|
config: Arc<JellyfinConfig>,
|
||||||
|
http_client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JellyfinClient {
|
||||||
|
/// Create a new Jellyfin API client
|
||||||
|
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
|
||||||
|
let http_client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
config: Arc::new(config),
|
||||||
|
http_client,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get device name based on platform
|
||||||
|
fn get_device_name() -> &'static str {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
return "Android";
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
return "Linux";
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return "Windows";
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return "macOS";
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
return "iOS";
|
||||||
|
#[cfg(not(any(
|
||||||
|
target_os = "android",
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "windows",
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "ios"
|
||||||
|
)))]
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the X-Emby-Authorization header value
|
||||||
|
fn get_auth_header(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
|
||||||
|
APP_NAME,
|
||||||
|
APP_VERSION,
|
||||||
|
Self::get_device_name(),
|
||||||
|
self.config.device_id,
|
||||||
|
self.config.access_token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a GET request to the Jellyfin API
|
||||||
|
async fn get<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, String> {
|
||||||
|
let url = format!("{}{}", self.config.server_url, endpoint);
|
||||||
|
|
||||||
|
log::debug!("[JellyfinClient] GET {}", endpoint);
|
||||||
|
|
||||||
|
let response = self.http_client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Emby-Authorization", self.get_auth_header())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
|
||||||
|
format!("Network request failed: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
|
||||||
|
log::error!("[JellyfinClient] Response: {}", error_text);
|
||||||
|
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the response text first so we can log it
|
||||||
|
let response_text = response.text().await.map_err(|e| {
|
||||||
|
log::error!("[JellyfinClient] Failed to read response body: {}", e);
|
||||||
|
format!("Failed to read response: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Log the raw response for sessions endpoint to help debug
|
||||||
|
if endpoint.contains("/Sessions") {
|
||||||
|
debug!("[JellyfinClient] Raw response for {}: {}", endpoint,
|
||||||
|
if response_text.len() > 500 {
|
||||||
|
format!("{}... (truncated, {} bytes total)", &response_text[..500], response_text.len())
|
||||||
|
} else {
|
||||||
|
response_text.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response text as JSON
|
||||||
|
let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
|
||||||
|
log::error!("[JellyfinClient] Failed to parse response: {}", e);
|
||||||
|
log::error!("[JellyfinClient] Response was: {}",
|
||||||
|
if response_text.len() > 200 {
|
||||||
|
format!("{}...", &response_text[..200])
|
||||||
|
} else {
|
||||||
|
response_text.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
format!("Failed to parse response: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a POST request to the Jellyfin API
|
||||||
|
async fn post<T: serde::Serialize>(&self, endpoint: &str, body: &T) -> Result<(), String> {
|
||||||
|
let url = format!("{}{}", self.config.server_url, endpoint);
|
||||||
|
|
||||||
|
log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
|
||||||
|
|
||||||
|
let response: reqwest::Response = self.http_client
|
||||||
|
.post(&url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Emby-Authorization", self.get_auth_header())
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
|
||||||
|
format!("Network request failed: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text: String = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
|
||||||
|
log::error!("[JellyfinClient] Response: {}", error_text);
|
||||||
|
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback start to Jellyfin
|
||||||
|
pub async fn report_playback_start(
|
||||||
|
&self,
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
play_session_id: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let request = PlaybackStartRequest {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
play_session_id,
|
||||||
|
play_command: "PlayNow".to_string(),
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.post("/Sessions/Playing", &request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback stopped to Jellyfin
|
||||||
|
pub async fn report_playback_stopped(
|
||||||
|
&self,
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
play_session_id: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let request = PlaybackStoppedRequest {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
play_session_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.post("/Sessions/Playing/Stopped", &request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report playback progress to Jellyfin
|
||||||
|
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||||
|
pub async fn report_playback_progress(
|
||||||
|
&self,
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
is_paused: bool,
|
||||||
|
play_session_id: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let request = PlaybackProgressRequest {
|
||||||
|
item_id,
|
||||||
|
position_ticks,
|
||||||
|
is_paused,
|
||||||
|
play_session_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.post("/Sessions/Playing/Progress", &request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play items on a remote session (casting)
|
||||||
|
pub async fn play_on_session(
|
||||||
|
&self,
|
||||||
|
session_id: String,
|
||||||
|
item_ids: Vec<String>,
|
||||||
|
start_index: usize,
|
||||||
|
start_position_ticks: Option<i64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[JellyfinClient] Playing on session: {}", session_id);
|
||||||
|
log::info!("[JellyfinClient] Item IDs: {:?}, Start index: {}", item_ids, start_index);
|
||||||
|
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
||||||
|
session_id, item_ids.len(), start_index);
|
||||||
|
|
||||||
|
// Build URL with query parameters (Jellyfin expects query params, not JSON body!)
|
||||||
|
let mut url = format!(
|
||||||
|
"{}/Sessions/{}/Playing?playCommand=PlayNow&startIndex={}",
|
||||||
|
self.config.server_url, session_id, start_index
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add item IDs as repeated query parameters
|
||||||
|
for item_id in &item_ids {
|
||||||
|
url.push_str(&format!("&itemIds={}", item_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add start position if provided
|
||||||
|
if let Some(ticks) = start_position_ticks {
|
||||||
|
url.push_str(&format!("&startPositionTicks={}", ticks));
|
||||||
|
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[JellyfinClient] POST {}", url);
|
||||||
|
debug!("[JellyfinClient] Full URL length: {} chars", url.len());
|
||||||
|
// Don't log full URL as it may contain sensitive tokens, just log the endpoint
|
||||||
|
debug!("[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds", session_id, item_ids.len());
|
||||||
|
|
||||||
|
debug!("[JellyfinClient] Sending HTTP POST request...");
|
||||||
|
let response = self.http_client
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Emby-Authorization", self.get_auth_header())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("[JellyfinClient] Request failed: {}", e);
|
||||||
|
error!("[JellyfinClient] HTTP request failed: {}", e);
|
||||||
|
format!("Network request failed: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
log::debug!("[JellyfinClient] Response status: {}", status);
|
||||||
|
debug!("[JellyfinClient] Response status: {}", status);
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
log::error!("[JellyfinClient] Request failed: {}", error_text);
|
||||||
|
error!("[JellyfinClient] API error {}: {}", status.as_u16(), error_text);
|
||||||
|
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[JellyfinClient] Successfully sent play command to remote session");
|
||||||
|
info!("[JellyfinClient] Play command sent to remote session");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a playback command to a remote session
|
||||||
|
pub async fn send_session_command(
|
||||||
|
&self,
|
||||||
|
session_id: String,
|
||||||
|
command: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.post(&format!("/Sessions/{}/Playing/{}", session_id, command), &serde_json::json!({})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek on a remote session
|
||||||
|
pub async fn session_seek(
|
||||||
|
&self,
|
||||||
|
session_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct SeekRequest {
|
||||||
|
seek_position_ticks: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = SeekRequest {
|
||||||
|
seek_position_ticks: position_ticks,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume on a remote session
|
||||||
|
pub async fn session_set_volume(
|
||||||
|
&self,
|
||||||
|
session_id: String,
|
||||||
|
volume: i32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"Arguments": {
|
||||||
|
"Volume": volume.to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log::info!("[JellyfinClient] Setting volume on session {} to {} with payload: {}",
|
||||||
|
session_id, volume, serde_json::to_string(&payload).unwrap_or_default());
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
&format!("/Sessions/{}/Command/SetVolume", session_id),
|
||||||
|
&payload
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle mute on a remote session
|
||||||
|
pub async fn session_toggle_mute(
|
||||||
|
&self,
|
||||||
|
session_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
&format!("/Sessions/{}/Command/ToggleMute", session_id),
|
||||||
|
&serde_json::json!({})
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all active sessions
|
||||||
|
pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
|
||||||
|
let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
|
||||||
|
info!("[JellyfinClient] Fetched {} sessions from API", sessions.len());
|
||||||
|
for session in &sessions {
|
||||||
|
debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
|
||||||
|
session.id, session.device_name, session.client, session.supports_remote_control);
|
||||||
|
}
|
||||||
|
Ok(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific session by ID
|
||||||
|
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
|
||||||
|
let sessions = self.get_sessions().await?;
|
||||||
|
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default value for supports_remote_control when missing from API
|
||||||
|
/// We default to true to show all sessions. If a session explicitly doesn't
|
||||||
|
/// support remote control, the Jellyfin API will set this field to false.
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session information from Jellyfin
|
||||||
|
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub device_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub device_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub application_version: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub supports_media_control: Option<bool>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub supports_remote_control: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub now_playing_item: Option<NowPlayingItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub play_state: Option<PlayState>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub playable_media_types: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub supported_commands: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
||||||
|
pub struct NowPlayingItem {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub run_time_ticks: Option<i64>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub album_id: Option<String>,
|
||||||
|
pub album_artist: Option<String>,
|
||||||
|
pub artists: Option<Vec<String>>,
|
||||||
|
pub image_tags: Option<std::collections::HashMap<String, String>>,
|
||||||
|
pub primary_image_tag: Option<String>,
|
||||||
|
pub album_primary_image_tag: Option<String>,
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub item_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||||
|
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
||||||
|
pub struct PlayState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub position_ticks: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub can_seek: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_paused: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_muted: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub volume_level: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub repeat_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub shuffle_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_header_format() {
|
||||||
|
let config = JellyfinConfig {
|
||||||
|
server_url: "http://localhost:8096".to_string(),
|
||||||
|
access_token: "test_token".to_string(),
|
||||||
|
device_id: "device456".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = JellyfinClient::new(config).unwrap();
|
||||||
|
let header = client.get_auth_header();
|
||||||
|
|
||||||
|
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
|
||||||
|
assert!(header.contains("Token=\"test_token\""));
|
||||||
|
assert!(header.contains("DeviceId=\"device456\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
262
src-tauri/src/jellyfin/http_client.rs
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
use reqwest::{Client, Request, Response, StatusCode};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const APP_NAME: &str = "JellyTau";
|
||||||
|
const APP_VERSION: &str = "0.1.0";
|
||||||
|
|
||||||
|
// Default timeout for requests (10 seconds)
|
||||||
|
const DEFAULT_TIMEOUT_MS: u64 = 10000;
|
||||||
|
|
||||||
|
// Retry configuration - matches TypeScript exactly
|
||||||
|
const DEFAULT_MAX_RETRIES: u32 = 3;
|
||||||
|
const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 4000]; // Exponential backoff
|
||||||
|
|
||||||
|
/// HTTP client configuration
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HttpConfig {
|
||||||
|
pub timeout: Duration,
|
||||||
|
pub max_retries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
|
||||||
|
max_retries: DEFAULT_MAX_RETRIES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error classification for retry logic
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ErrorKind {
|
||||||
|
Network,
|
||||||
|
Authentication,
|
||||||
|
Server,
|
||||||
|
Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enhanced HTTP client with retry logic and error classification
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpClient {
|
||||||
|
pub(crate) client: Client, // Make accessible within crate for custom requests
|
||||||
|
config: HttpConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpClient {
|
||||||
|
/// Create a new HTTP client with default configuration
|
||||||
|
pub fn new(config: HttpConfig) -> Result<Self, String> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(config.timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Self { client, config })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get device name based on platform
|
||||||
|
fn get_device_name() -> &'static str {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
return "Android";
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
return "Linux";
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
return "Windows";
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return "macOS";
|
||||||
|
#[cfg(target_os = "ios")]
|
||||||
|
return "iOS";
|
||||||
|
#[cfg(not(any(
|
||||||
|
target_os = "android",
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "windows",
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "ios"
|
||||||
|
)))]
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the X-Emby-Authorization header value
|
||||||
|
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
|
||||||
|
let mut parts = vec![
|
||||||
|
format!("MediaBrowser Client=\"{}\"", APP_NAME),
|
||||||
|
format!("Version=\"{}\"", APP_VERSION),
|
||||||
|
format!("Device=\"{}\"", Self::get_device_name()),
|
||||||
|
format!("DeviceId=\"{}\"", device_id),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(token) = access_token {
|
||||||
|
parts.push(format!("Token=\"{}\"", token));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify an error for retry logic
|
||||||
|
pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
|
||||||
|
// Network errors (connection failures, timeouts, DNS failures)
|
||||||
|
if error.is_timeout() || error.is_connect() {
|
||||||
|
return ErrorKind::Network;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status code if available
|
||||||
|
if let Some(status) = error.status() {
|
||||||
|
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||||
|
return ErrorKind::Authentication;
|
||||||
|
} else if status.is_server_error() {
|
||||||
|
return ErrorKind::Server;
|
||||||
|
} else if status.is_client_error() {
|
||||||
|
return ErrorKind::Client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no status code, check error message for network-related keywords
|
||||||
|
let error_msg = error.to_string().to_lowercase();
|
||||||
|
if error_msg.contains("network")
|
||||||
|
|| error_msg.contains("connection")
|
||||||
|
|| error_msg.contains("timeout")
|
||||||
|
|| error_msg.contains("dns")
|
||||||
|
|| error_msg.contains("refused")
|
||||||
|
|| error_msg.contains("reset")
|
||||||
|
{
|
||||||
|
return ErrorKind::Network;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to client error
|
||||||
|
ErrorKind::Client
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a request should be retried based on the error
|
||||||
|
pub fn should_retry(error: &reqwest::Error) -> bool {
|
||||||
|
match Self::classify_error(error) {
|
||||||
|
ErrorKind::Network => true, // Retry network errors
|
||||||
|
ErrorKind::Server => true, // Retry 5xx server errors
|
||||||
|
ErrorKind::Authentication => false, // Don't retry 401/403
|
||||||
|
ErrorKind::Client => false, // Don't retry other 4xx errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a request with automatic retry on network errors
|
||||||
|
pub async fn request_with_retry(
|
||||||
|
&self,
|
||||||
|
request: Request,
|
||||||
|
) -> Result<Response, reqwest::Error> {
|
||||||
|
let max_retries = self.config.max_retries;
|
||||||
|
let mut last_error: Option<reqwest::Error> = None;
|
||||||
|
|
||||||
|
for attempt in 0..=max_retries {
|
||||||
|
// Clone the request for retry attempts
|
||||||
|
// If request cannot be cloned (e.g., streaming body), we cannot retry
|
||||||
|
let Some(req) = request.try_clone() else {
|
||||||
|
log::warn!("[HttpClient] Request body cannot be cloned, retries not possible");
|
||||||
|
return self.client.execute(request).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.client.execute(req).await {
|
||||||
|
Ok(response) => return Ok(response),
|
||||||
|
Err(error) => {
|
||||||
|
last_error = Some(error);
|
||||||
|
let err = last_error.as_ref().unwrap();
|
||||||
|
|
||||||
|
// Don't retry if it's not a retryable error
|
||||||
|
if !Self::should_retry(err) {
|
||||||
|
return Err(last_error.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry on last attempt
|
||||||
|
if attempt == max_retries {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retrying (exponential backoff)
|
||||||
|
let delay_ms = RETRY_DELAYS_MS
|
||||||
|
.get(attempt as usize)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(*RETRY_DELAYS_MS.last().unwrap());
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[HttpClient] Retry {}/{} after {}ms (error: {})",
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
delay_ms,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a GET request with retry
|
||||||
|
pub async fn get_with_retry(&self, url: &str) -> Result<Response, reqwest::Error> {
|
||||||
|
let request = self.client.get(url).build()?;
|
||||||
|
self.request_with_retry(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a GET request and deserialize JSON response with retry
|
||||||
|
pub async fn get_json_with_retry<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
let response = self.get_with_retry(url).await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await
|
||||||
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
return Err(format!("HTTP {}: {}", status, error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json::<T>().await
|
||||||
|
.map_err(|e| format!("Failed to parse JSON: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quick ping to check if a server is reachable (no retry)
|
||||||
|
pub async fn ping(&self, url: &str) -> bool {
|
||||||
|
let request = self.client.get(url)
|
||||||
|
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
|
||||||
|
.build();
|
||||||
|
|
||||||
|
match request {
|
||||||
|
Ok(req) => {
|
||||||
|
match self.client.execute(req).await {
|
||||||
|
Ok(response) => response.status().is_success(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_header_format() {
|
||||||
|
let header = HttpClient::build_auth_header(Some("test_token"), "device456");
|
||||||
|
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
|
||||||
|
assert!(header.contains("Token=\"test_token\""));
|
||||||
|
assert!(header.contains("DeviceId=\"device456\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_header_without_token() {
|
||||||
|
let header = HttpClient::build_auth_header(None, "device456");
|
||||||
|
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
|
||||||
|
assert!(!header.contains("Token="));
|
||||||
|
assert!(header.contains("DeviceId=\"device456\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retry_delays() {
|
||||||
|
// Verify retry delays match TypeScript
|
||||||
|
assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src-tauri/src/jellyfin/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod http_client;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use client::{JellyfinClient, NowPlayingItem};
|
||||||
|
pub use http_client::{HttpClient, HttpConfig};
|
||||||
|
pub use types::*;
|
||||||
43
src-tauri/src/jellyfin/types.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Configuration for Jellyfin API client
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JellyfinConfig {
|
||||||
|
pub server_url: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for reporting playback start
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct PlaybackStartRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub position_ticks: i64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub play_session_id: Option<String>,
|
||||||
|
pub play_command: String,
|
||||||
|
pub is_paused: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for reporting playback stopped
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct PlaybackStoppedRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub position_ticks: i64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub play_session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for reporting playback progress
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||||
|
pub struct PlaybackProgressRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub position_ticks: i64,
|
||||||
|
pub is_paused: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub play_session_id: Option<String>,
|
||||||
|
}
|
||||||
776
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,776 @@
|
|||||||
|
mod auth;
|
||||||
|
mod commands;
|
||||||
|
mod connectivity;
|
||||||
|
mod credentials;
|
||||||
|
mod download;
|
||||||
|
mod jellyfin;
|
||||||
|
mod playback_mode;
|
||||||
|
mod playback_reporting;
|
||||||
|
mod player;
|
||||||
|
mod repository;
|
||||||
|
mod session_poller;
|
||||||
|
pub mod settings;
|
||||||
|
mod storage;
|
||||||
|
mod thumbnail;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
use tauri::Manager;
|
||||||
|
use log::{error, info};
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use log::warn;
|
||||||
|
|
||||||
|
use commands::{
|
||||||
|
cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download,
|
||||||
|
download_album, download_item, download_item_and_start, download_video, download_series, download_season,
|
||||||
|
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
|
||||||
|
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
|
||||||
|
get_album_affinity_status,
|
||||||
|
mark_download_completed, mark_download_failed, start_download,
|
||||||
|
pin_item, unpin_item, is_item_pinned,
|
||||||
|
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
|
||||||
|
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
|
||||||
|
player_get_video_settings, player_next, player_pause, player_play, player_play_album_track,
|
||||||
|
player_play_item, player_play_queue, player_play_tracks, player_previous, player_seek, player_seek_video, player_set_audio_settings, player_set_audio_track, player_switch_audio_track,
|
||||||
|
player_set_subtitle_track, player_set_video_settings, player_set_volume, player_toggle_mute, player_stop, player_toggle,
|
||||||
|
player_toggle_shuffle,
|
||||||
|
// Sleep timer and autoplay commands
|
||||||
|
player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer,
|
||||||
|
player_get_autoplay_settings, player_set_autoplay_settings,
|
||||||
|
player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended,
|
||||||
|
// Queue manipulation commands
|
||||||
|
player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids,
|
||||||
|
player_remove_from_queue, player_move_in_queue, player_skip_to,
|
||||||
|
// Preload commands
|
||||||
|
player_preload_upcoming, player_set_cache_config, player_get_cache_config,
|
||||||
|
// Jellyfin reporting commands
|
||||||
|
player_configure_jellyfin, player_disable_jellyfin,
|
||||||
|
// Session management commands
|
||||||
|
player_get_session, player_dismiss_session,
|
||||||
|
// Remote session control commands
|
||||||
|
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
|
||||||
|
remote_session_toggle_mute,
|
||||||
|
// Session polling commands
|
||||||
|
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
|
||||||
|
// Playback mode commands
|
||||||
|
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
|
||||||
|
playback_mode_transfer_to_remote, playback_mode_transfer_to_local,
|
||||||
|
playback_mode_get_remote_status,
|
||||||
|
// Playback reporting commands
|
||||||
|
playback_reporter_init, playback_reporter_destroy,
|
||||||
|
playback_report_start, playback_report_progress, playback_report_stopped,
|
||||||
|
playback_mark_played, PlaybackReporterWrapper,
|
||||||
|
// Auth commands
|
||||||
|
auth_initialize, auth_connect_to_server, auth_login, auth_verify_session,
|
||||||
|
auth_logout, auth_get_session, auth_set_session, auth_start_verification,
|
||||||
|
auth_stop_verification, auth_reauthenticate,
|
||||||
|
// Connectivity commands
|
||||||
|
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
|
||||||
|
connectivity_start_monitoring, connectivity_stop_monitoring,
|
||||||
|
connectivity_mark_reachable, connectivity_mark_unreachable,
|
||||||
|
// Storage commands
|
||||||
|
storage_delete_server, storage_delete_user, storage_get_access_token,
|
||||||
|
storage_get_active_session, storage_get_active_user, storage_get_path,
|
||||||
|
storage_get_playback_progress, storage_get_security_status, storage_get_servers, storage_get_size,
|
||||||
|
storage_get_users, storage_init, storage_mark_played, storage_mark_synced, storage_save_server,
|
||||||
|
storage_save_user, storage_set_active_user, storage_toggle_favorite, storage_update_playback_progress,
|
||||||
|
storage_update_playback_context,
|
||||||
|
// Offline cache commands
|
||||||
|
storage_get_libraries, storage_get_items, storage_get_item, storage_search_items,
|
||||||
|
storage_save_library, storage_save_item, storage_get_pending_sync_count,
|
||||||
|
// Sync queue commands
|
||||||
|
sync_queue_mutation, sync_get_pending, sync_mark_processing, sync_mark_completed,
|
||||||
|
sync_mark_failed, sync_get_pending_count, sync_cleanup_completed, sync_clear_user,
|
||||||
|
// Thumbnail cache and image commands
|
||||||
|
thumbnail_get_cached, thumbnail_save, thumbnail_get_stats, thumbnail_set_limit,
|
||||||
|
thumbnail_clear_cache, thumbnail_delete_item, image_get_url,
|
||||||
|
// People cache commands
|
||||||
|
storage_save_person, storage_get_person, storage_save_item_people, storage_get_item_people,
|
||||||
|
// Series audio preferences
|
||||||
|
storage_save_series_audio_preference, storage_get_series_audio_preference,
|
||||||
|
// Repository commands
|
||||||
|
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
|
||||||
|
repository_get_item, repository_get_latest_items, repository_get_resume_items,
|
||||||
|
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
|
||||||
|
repository_get_genres, repository_search, repository_get_playback_info,
|
||||||
|
repository_get_video_stream_url, repository_get_audio_stream_url,
|
||||||
|
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
|
||||||
|
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
|
||||||
|
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
|
||||||
|
// Conversion commands
|
||||||
|
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
|
||||||
|
calc_progress, convert_percent_to_volume,
|
||||||
|
AuthManagerWrapper, SessionVerifierWrapper,
|
||||||
|
ConnectivityMonitorWrapper, CredentialStoreWrapper, DatabaseWrapper, PlayerStateWrapper,
|
||||||
|
MediaSessionManagerWrapper, VideoSettingsWrapper, ThumbnailCacheWrapper, SmartCacheWrapper,
|
||||||
|
PlaybackModeManagerWrapper, RepositoryManagerWrapper, DownloadManagerWrapper,
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use playback_mode::PlaybackModeManager;
|
||||||
|
use auth::AuthManager;
|
||||||
|
use connectivity::ConnectivityMonitor;
|
||||||
|
use credentials::CredentialStore;
|
||||||
|
use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
|
||||||
|
use download::DownloadManager;
|
||||||
|
use jellyfin::{HttpClient, HttpConfig};
|
||||||
|
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
|
||||||
|
// NullBackend fallback for platforms without native backends (not Linux or Android)
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
|
use player::NullBackend;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use player::MpvBackend;
|
||||||
|
use settings::VideoSettings;
|
||||||
|
use storage::Database;
|
||||||
|
use thumbnail::{ThumbnailCache, CacheConfig as ThumbnailCacheConfig};
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use credentials::initialize_secure_storage;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use player::ExoPlayerBackend;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler, set_remote_volume_handler};
|
||||||
|
|
||||||
|
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
|
||||||
|
///
|
||||||
|
/// Routes commands from the system media controls back to the PlayerController.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
struct MediaSessionHandler {
|
||||||
|
player: Arc<TokioMutex<PlayerController>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
impl MediaCommandHandler for MediaSessionHandler {
|
||||||
|
fn on_command(&self, command: &str) {
|
||||||
|
// Use blocking_lock since this is called from a non-async JNI callback
|
||||||
|
let controller = self.player.blocking_lock();
|
||||||
|
|
||||||
|
match command {
|
||||||
|
"play" => {
|
||||||
|
if let Err(e) = controller.play() {
|
||||||
|
error!("[MediaSession] Play failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pause" => {
|
||||||
|
if let Err(e) = controller.pause() {
|
||||||
|
error!("[MediaSession] Pause failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"next" => {
|
||||||
|
if let Err(e) = controller.next() {
|
||||||
|
error!("[MediaSession] Next failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"previous" => {
|
||||||
|
if let Err(e) = controller.previous() {
|
||||||
|
error!("[MediaSession] Previous failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"stop" => {
|
||||||
|
if let Err(e) = controller.stop() {
|
||||||
|
error!("[MediaSession] Stop failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd if cmd.starts_with("seek:") => {
|
||||||
|
if let Ok(pos) = cmd[5..].parse::<f64>() {
|
||||||
|
if let Err(e) = controller.seek(pos) {
|
||||||
|
error!("[MediaSession] Seek failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("[MediaSession] Unknown command: {}", command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler for remote volume changes from Android volume buttons when in remote playback mode.
|
||||||
|
///
|
||||||
|
/// Routes volume commands to the Jellyfin session via the playback mode manager.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
struct RemoteVolumeSessionHandler {
|
||||||
|
playback_mode: Arc<PlaybackModeManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
|
||||||
|
fn on_remote_volume_change(&self, command: &str, volume: i32) {
|
||||||
|
log::info!("[RemoteVolume] Command: {}, Volume: {}", command, volume);
|
||||||
|
|
||||||
|
// Send the volume command to the remote session asynchronously
|
||||||
|
let playback_mode = Arc::clone(&self.playback_mode);
|
||||||
|
let command_str = command.to_string();
|
||||||
|
|
||||||
|
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
||||||
|
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
||||||
|
log::info!("[RemoteVolume] Spawning async task to send volume command...");
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
|
||||||
|
match playback_mode.send_remote_volume_command(&command_str, volume).await {
|
||||||
|
Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
|
||||||
|
Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
|
||||||
|
}
|
||||||
|
log::info!("[RemoteVolume] Async task completed");
|
||||||
|
});
|
||||||
|
log::info!("[RemoteVolume] Async task spawned, returning from JNI callback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the appropriate player backend for the current platform.
|
||||||
|
fn create_player_backend(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
||||||
|
position_throttler: Arc<playback_reporting::EventThrottler>,
|
||||||
|
) -> Box<dyn PlayerBackend> {
|
||||||
|
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
info!("Android platform detected - initializing ExoPlayer backend");
|
||||||
|
|
||||||
|
// Get the Android context via ndk-context
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
|
||||||
|
// Get JavaVM and create JNI environment
|
||||||
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
||||||
|
|
||||||
|
match vm {
|
||||||
|
Ok(java_vm) => {
|
||||||
|
match java_vm.attach_current_thread() {
|
||||||
|
Ok(mut env) => {
|
||||||
|
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
|
||||||
|
|
||||||
|
match ExoPlayerBackend::new(&mut env, &context_obj, _event_emitter.clone(), playback_reporter.clone(), position_throttler.clone()) {
|
||||||
|
Ok(backend) => {
|
||||||
|
info!("Successfully initialized ExoPlayer backend for Android");
|
||||||
|
return Box::new(backend);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
panic!("FATAL: Failed to initialize ExoPlayer backend on Android: {}. This is a critical error - playback will not work.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
panic!("FATAL: Failed to attach JNI thread on Android: {}. This is a critical error - playback will not work.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
panic!("FATAL: Failed to create JavaVM on Android: {}. This is a critical error - playback will not work.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Linux, use MPV backend for audio playback
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
info!("Linux platform detected - initializing MPV backend for audio");
|
||||||
|
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
|
||||||
|
Ok(backend) => {
|
||||||
|
info!("Successfully initialized MPV backend for Linux");
|
||||||
|
return Box::new(backend);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("\n========================================");
|
||||||
|
error!("FATAL ERROR: Failed to initialize MPV backend");
|
||||||
|
error!("========================================");
|
||||||
|
error!("Error: {}", e);
|
||||||
|
error!("\nCommon causes:");
|
||||||
|
error!(" 1. MPV is not installed");
|
||||||
|
error!(" Solution: Install MPV using your package manager");
|
||||||
|
error!(" - Arch/CachyOS: sudo pacman -S mpv");
|
||||||
|
error!(" - Ubuntu/Debian: sudo apt install mpv libmpv-dev");
|
||||||
|
error!(" - Fedora: sudo dnf install mpv mpv-libs-devel");
|
||||||
|
error!("\n 2. MPV version mismatch (app was built with different libmpv version)");
|
||||||
|
error!(" Solution: Rebuild the application");
|
||||||
|
error!(" - cd src-tauri && cargo clean && cargo build --release");
|
||||||
|
error!("\n 3. Audio system not working");
|
||||||
|
error!(" Solution: Verify audio works with: pactl info");
|
||||||
|
error!("\nAudio playback will NOT work until this is fixed.");
|
||||||
|
error!("========================================\n");
|
||||||
|
|
||||||
|
panic!("Cannot start application: MPV backend initialization failed. See error message above.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for other platforms
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
|
{
|
||||||
|
warn!("WARNING: No audio backend available for this platform");
|
||||||
|
Box::new(NullBackend::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
// Initialize logger
|
||||||
|
env_logger::Builder::from_default_env()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
|
.setup(|app| {
|
||||||
|
// Initialize database with proper app data directory
|
||||||
|
// Check for test mode environment variable first
|
||||||
|
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||||
|
let test_path = std::path::PathBuf::from(test_data_dir);
|
||||||
|
info!("[INIT] Using test data directory: {:?}", test_path);
|
||||||
|
test_path.join("jellytau.db")
|
||||||
|
} else {
|
||||||
|
app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.expect("Failed to get app data directory")
|
||||||
|
.join("jellytau.db")
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("[INIT] Initializing database at: {:?}", db_path);
|
||||||
|
|
||||||
|
// Create the directory if it doesn't exist
|
||||||
|
if let Some(parent) = db_path.parent() {
|
||||||
|
info!("[INIT] Creating database directory: {:?}", parent);
|
||||||
|
match std::fs::create_dir_all(parent) {
|
||||||
|
Ok(_) => info!("[INIT] Database directory ready"),
|
||||||
|
Err(e) => {
|
||||||
|
error!("[INIT ERROR] Failed to create database directory: {}", e);
|
||||||
|
panic!("Failed to create database directory: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("[INIT] Opening database...");
|
||||||
|
let database = match Database::open(&db_path) {
|
||||||
|
Ok(db) => {
|
||||||
|
info!("[INIT] Database initialized successfully");
|
||||||
|
db
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("[INIT ERROR] Failed to initialize database: {}", e);
|
||||||
|
panic!("Failed to initialize database: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let db_wrapper = DatabaseWrapper(Mutex::new(database));
|
||||||
|
app.manage(db_wrapper);
|
||||||
|
|
||||||
|
// On Android, initialize SecureStorage BEFORE creating CredentialStore
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
info!("[INIT] Initializing Android SecureStorage for credentials...");
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
|
||||||
|
|
||||||
|
match vm {
|
||||||
|
Ok(java_vm) => {
|
||||||
|
match java_vm.attach_current_thread() {
|
||||||
|
Ok(mut env) => {
|
||||||
|
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
|
||||||
|
|
||||||
|
match initialize_secure_storage(&mut env, &context_obj) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("[INIT] Android SecureStorage initialized successfully");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[INIT WARNING] Failed to initialize SecureStorage: {}. Credentials will use encrypted file fallback.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[INIT WARNING] Failed to attach JNI thread: {}. Credentials will use encrypted file fallback.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[INIT WARNING] Failed to create JavaVM: {}. Credentials will use encrypted file fallback.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize credential store (keyring with encrypted file fallback)
|
||||||
|
info!("[INIT] Initializing credential store...");
|
||||||
|
let credential_store = CredentialStore::new();
|
||||||
|
let creds_wrapper = CredentialStoreWrapper(Mutex::new(credential_store));
|
||||||
|
app.manage(creds_wrapper);
|
||||||
|
|
||||||
|
// Create shared reporter and throttler Arc wrappers before backend/controller
|
||||||
|
info!("[INIT] Creating shared playback reporting infrastructure...");
|
||||||
|
let playback_reporter = Arc::new(tokio::sync::Mutex::new(None));
|
||||||
|
let position_throttler = Arc::new(playback_reporting::EventThrottler::new());
|
||||||
|
|
||||||
|
// Create player backend with access to AppHandle for event emission
|
||||||
|
info!("[INIT] Creating player backend...");
|
||||||
|
let backend = create_player_backend(
|
||||||
|
app.handle().clone(),
|
||||||
|
playback_reporter.clone(),
|
||||||
|
position_throttler.clone(),
|
||||||
|
);
|
||||||
|
let player_controller = PlayerController::new(
|
||||||
|
backend,
|
||||||
|
playback_reporter.clone(),
|
||||||
|
position_throttler.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wire up event emitter for sleep timer and autoplay notifications
|
||||||
|
let event_emitter = Arc::new(TauriEventEmitter::new(app.handle().clone()));
|
||||||
|
player_controller.set_event_emitter(event_emitter.clone());
|
||||||
|
|
||||||
|
let player_arc = Arc::new(TokioMutex::new(player_controller));
|
||||||
|
|
||||||
|
// On Android, set up the MediaSession handler for lockscreen controls
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
|
||||||
|
let handler = Arc::new(MediaSessionHandler {
|
||||||
|
player: player_arc.clone(),
|
||||||
|
});
|
||||||
|
set_media_command_handler(handler);
|
||||||
|
|
||||||
|
// Register player controller for autoplay decisions
|
||||||
|
player::android::set_player_controller(player_arc.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let player_state = PlayerStateWrapper(player_arc.clone());
|
||||||
|
app.manage(player_state);
|
||||||
|
|
||||||
|
// Initialize media session manager
|
||||||
|
info!("[INIT] Initializing media session manager...");
|
||||||
|
let session_manager = MediaSessionManager::new();
|
||||||
|
let session_wrapper = MediaSessionManagerWrapper(Mutex::new(session_manager));
|
||||||
|
app.manage(session_wrapper);
|
||||||
|
|
||||||
|
// Initialize playback mode manager
|
||||||
|
info!("[INIT] Initializing playback mode manager...");
|
||||||
|
let jellyfin_client = {
|
||||||
|
let player = player_arc.blocking_lock();
|
||||||
|
player.jellyfin_client()
|
||||||
|
};
|
||||||
|
let playback_mode_manager = playback_mode::PlaybackModeManager::new(
|
||||||
|
jellyfin_client.clone(),
|
||||||
|
player_arc.clone(),
|
||||||
|
);
|
||||||
|
let playback_mode_arc = Arc::new(playback_mode_manager);
|
||||||
|
let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
|
||||||
|
app.manage(playback_mode_wrapper);
|
||||||
|
|
||||||
|
// Initialize session poller manager for remote session polling
|
||||||
|
info!("[INIT] Initializing session poller manager...");
|
||||||
|
let session_poller = session_poller::SessionPollerManager::new(
|
||||||
|
jellyfin_client,
|
||||||
|
playback_mode_arc.clone(),
|
||||||
|
);
|
||||||
|
session_poller.set_event_emitter(event_emitter.clone());
|
||||||
|
session_poller.start();
|
||||||
|
let session_poller_arc = Arc::new(session_poller);
|
||||||
|
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
|
||||||
|
app.manage(session_poller_wrapper);
|
||||||
|
|
||||||
|
// On Android, set up remote volume handler for volume button intercept in remote mode
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
info!("[INIT] Setting up remote volume handler for Android...");
|
||||||
|
let handler = Arc::new(RemoteVolumeSessionHandler {
|
||||||
|
playback_mode: playback_mode_arc.clone(),
|
||||||
|
});
|
||||||
|
set_remote_volume_handler(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize video settings with defaults
|
||||||
|
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||||
|
app.manage(video_settings);
|
||||||
|
|
||||||
|
// Initialize thumbnail cache
|
||||||
|
info!("[INIT] Initializing thumbnail cache...");
|
||||||
|
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||||
|
std::path::PathBuf::from(test_data_dir)
|
||||||
|
} else {
|
||||||
|
app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.expect("Failed to get app data directory")
|
||||||
|
};
|
||||||
|
let thumbnail_cache = ThumbnailCache::new(app_data_dir.clone(), ThumbnailCacheConfig::default());
|
||||||
|
let thumbnail_wrapper = ThumbnailCacheWrapper(Arc::new(thumbnail_cache));
|
||||||
|
app.manage(thumbnail_wrapper);
|
||||||
|
|
||||||
|
// Initialize smart cache for preloading
|
||||||
|
info!("[INIT] Initializing smart cache...");
|
||||||
|
let smart_cache = SmartCache::new(SmartCacheConfig::default());
|
||||||
|
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
|
||||||
|
app.manage(smart_cache_wrapper);
|
||||||
|
|
||||||
|
// Initialize download manager
|
||||||
|
info!("[INIT] Initializing download manager...");
|
||||||
|
let download_dir = app_data_dir.join("downloads");
|
||||||
|
let download_manager = DownloadManager::new(download_dir);
|
||||||
|
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
|
||||||
|
app.manage(download_manager_wrapper);
|
||||||
|
|
||||||
|
// Initialize connectivity monitor
|
||||||
|
info!("[INIT] Initializing connectivity monitor...");
|
||||||
|
let http_config = HttpConfig::default();
|
||||||
|
let http_client = HttpClient::new(http_config)
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
|
||||||
|
connectivity_monitor.set_app_handle(app.handle().clone());
|
||||||
|
|
||||||
|
// Wrap in Arc for sharing with AuthManager
|
||||||
|
let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
|
||||||
|
let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
|
||||||
|
app.manage(connectivity_wrapper);
|
||||||
|
|
||||||
|
// Initialize auth manager
|
||||||
|
info!("[INIT] Initializing auth manager...");
|
||||||
|
let auth_http_config = HttpConfig::default();
|
||||||
|
let auth_http_client = HttpClient::new(auth_http_config)
|
||||||
|
.expect("Failed to create HTTP client for auth");
|
||||||
|
let mut auth_manager = AuthManager::new(auth_http_client);
|
||||||
|
|
||||||
|
// Give auth manager a reference to connectivity monitor
|
||||||
|
auth_manager.set_connectivity_monitor(connectivity_arc.clone());
|
||||||
|
|
||||||
|
let auth_manager_wrapper = AuthManagerWrapper(Arc::new(auth_manager));
|
||||||
|
app.manage(auth_manager_wrapper);
|
||||||
|
|
||||||
|
// Initialize session verifier wrapper (initially empty)
|
||||||
|
info!("[INIT] Initializing session verifier wrapper...");
|
||||||
|
let session_verifier_wrapper = SessionVerifierWrapper(Arc::new(tokio::sync::Mutex::new(None)));
|
||||||
|
app.manage(session_verifier_wrapper);
|
||||||
|
|
||||||
|
// Initialize repository manager
|
||||||
|
info!("[INIT] Initializing repository manager...");
|
||||||
|
let repository_manager = commands::RepositoryManager::new();
|
||||||
|
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
|
||||||
|
app.manage(repository_manager_wrapper);
|
||||||
|
|
||||||
|
// Initialize playback reporter wrapper (initially empty, set on login)
|
||||||
|
info!("[INIT] Initializing playback reporter wrapper...");
|
||||||
|
let playback_reporter_wrapper = PlaybackReporterWrapper(Arc::new(tokio::sync::Mutex::new(None)));
|
||||||
|
app.manage(playback_reporter_wrapper);
|
||||||
|
|
||||||
|
info!("[INIT] Application setup completed successfully");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
// Player commands
|
||||||
|
player_play_item,
|
||||||
|
player_play_queue,
|
||||||
|
player_play_album_track,
|
||||||
|
player_play_tracks,
|
||||||
|
player_play,
|
||||||
|
player_pause,
|
||||||
|
player_toggle,
|
||||||
|
player_stop,
|
||||||
|
player_next,
|
||||||
|
player_previous,
|
||||||
|
player_seek,
|
||||||
|
player_seek_video,
|
||||||
|
player_set_volume,
|
||||||
|
player_toggle_mute,
|
||||||
|
player_set_audio_track,
|
||||||
|
player_switch_audio_track,
|
||||||
|
player_set_subtitle_track,
|
||||||
|
player_toggle_shuffle,
|
||||||
|
player_cycle_repeat,
|
||||||
|
player_get_status,
|
||||||
|
player_get_queue,
|
||||||
|
player_add_to_queue,
|
||||||
|
player_add_track_by_id,
|
||||||
|
player_add_tracks_by_ids,
|
||||||
|
player_remove_from_queue,
|
||||||
|
player_move_in_queue,
|
||||||
|
player_skip_to,
|
||||||
|
player_set_audio_settings,
|
||||||
|
player_get_audio_settings,
|
||||||
|
player_set_video_settings,
|
||||||
|
player_get_video_settings,
|
||||||
|
// Sleep timer and autoplay commands
|
||||||
|
player_set_sleep_timer,
|
||||||
|
player_cancel_sleep_timer,
|
||||||
|
player_get_sleep_timer,
|
||||||
|
player_get_autoplay_settings,
|
||||||
|
player_set_autoplay_settings,
|
||||||
|
player_cancel_autoplay_countdown,
|
||||||
|
player_play_next_episode,
|
||||||
|
player_on_playback_ended,
|
||||||
|
// Preload commands
|
||||||
|
player_preload_upcoming,
|
||||||
|
player_set_cache_config,
|
||||||
|
player_get_cache_config,
|
||||||
|
// Jellyfin reporting commands
|
||||||
|
player_configure_jellyfin,
|
||||||
|
player_disable_jellyfin,
|
||||||
|
// Session management commands
|
||||||
|
player_get_session,
|
||||||
|
player_dismiss_session,
|
||||||
|
// Remote session control commands
|
||||||
|
remote_play_on_session,
|
||||||
|
remote_send_command,
|
||||||
|
remote_session_seek,
|
||||||
|
remote_session_set_volume,
|
||||||
|
remote_session_toggle_mute,
|
||||||
|
// Session polling commands
|
||||||
|
sessions_set_polling_hint,
|
||||||
|
sessions_poll_now,
|
||||||
|
// Playback mode commands
|
||||||
|
playback_mode_get_current,
|
||||||
|
playback_mode_set,
|
||||||
|
playback_mode_is_transferring,
|
||||||
|
playback_mode_transfer_to_remote,
|
||||||
|
playback_mode_get_remote_status,
|
||||||
|
playback_mode_transfer_to_local,
|
||||||
|
// Playback reporting commands
|
||||||
|
playback_reporter_init,
|
||||||
|
playback_reporter_destroy,
|
||||||
|
playback_report_start,
|
||||||
|
playback_report_progress,
|
||||||
|
playback_report_stopped,
|
||||||
|
playback_mark_played,
|
||||||
|
// Auth commands
|
||||||
|
auth_initialize,
|
||||||
|
auth_connect_to_server,
|
||||||
|
auth_login,
|
||||||
|
auth_verify_session,
|
||||||
|
auth_logout,
|
||||||
|
auth_get_session,
|
||||||
|
auth_set_session,
|
||||||
|
auth_start_verification,
|
||||||
|
auth_stop_verification,
|
||||||
|
auth_reauthenticate,
|
||||||
|
// Connectivity commands
|
||||||
|
connectivity_check_server,
|
||||||
|
connectivity_set_server_url,
|
||||||
|
connectivity_get_status,
|
||||||
|
connectivity_start_monitoring,
|
||||||
|
connectivity_stop_monitoring,
|
||||||
|
connectivity_mark_reachable,
|
||||||
|
connectivity_mark_unreachable,
|
||||||
|
// Storage commands
|
||||||
|
storage_init,
|
||||||
|
storage_get_path,
|
||||||
|
storage_get_size,
|
||||||
|
storage_get_security_status,
|
||||||
|
storage_save_server,
|
||||||
|
storage_get_servers,
|
||||||
|
storage_delete_server,
|
||||||
|
storage_save_user,
|
||||||
|
storage_get_users,
|
||||||
|
storage_set_active_user,
|
||||||
|
storage_get_active_user,
|
||||||
|
storage_get_active_session,
|
||||||
|
storage_get_access_token,
|
||||||
|
storage_delete_user,
|
||||||
|
// Playback progress commands
|
||||||
|
storage_update_playback_progress,
|
||||||
|
storage_update_playback_context,
|
||||||
|
storage_mark_played,
|
||||||
|
storage_get_playback_progress,
|
||||||
|
storage_mark_synced,
|
||||||
|
storage_toggle_favorite,
|
||||||
|
// Download commands
|
||||||
|
download_item,
|
||||||
|
download_item_and_start,
|
||||||
|
download_album,
|
||||||
|
download_video,
|
||||||
|
download_series,
|
||||||
|
download_season,
|
||||||
|
get_downloads,
|
||||||
|
pause_download,
|
||||||
|
resume_download,
|
||||||
|
cancel_download,
|
||||||
|
delete_download,
|
||||||
|
delete_all_downloads,
|
||||||
|
delete_album_downloads,
|
||||||
|
clear_stale_downloads,
|
||||||
|
get_download_storage_stats,
|
||||||
|
mark_download_completed,
|
||||||
|
mark_download_failed,
|
||||||
|
start_download,
|
||||||
|
get_download_manager_stats,
|
||||||
|
set_max_concurrent_downloads,
|
||||||
|
get_smart_cache_stats,
|
||||||
|
update_smart_cache_config,
|
||||||
|
get_smart_cache_config,
|
||||||
|
get_album_recommendations,
|
||||||
|
get_album_affinity_status,
|
||||||
|
// Pinning commands
|
||||||
|
pin_item,
|
||||||
|
unpin_item,
|
||||||
|
is_item_pinned,
|
||||||
|
// Offline commands
|
||||||
|
offline_is_available,
|
||||||
|
offline_get_items,
|
||||||
|
offline_search,
|
||||||
|
// Offline cache commands
|
||||||
|
storage_get_libraries,
|
||||||
|
storage_get_items,
|
||||||
|
storage_get_item,
|
||||||
|
storage_search_items,
|
||||||
|
storage_save_library,
|
||||||
|
storage_save_item,
|
||||||
|
storage_get_pending_sync_count,
|
||||||
|
// Sync queue commands
|
||||||
|
sync_queue_mutation,
|
||||||
|
sync_get_pending,
|
||||||
|
sync_mark_processing,
|
||||||
|
sync_mark_completed,
|
||||||
|
sync_mark_failed,
|
||||||
|
sync_get_pending_count,
|
||||||
|
sync_cleanup_completed,
|
||||||
|
sync_clear_user,
|
||||||
|
// Thumbnail cache and image commands
|
||||||
|
thumbnail_get_cached,
|
||||||
|
thumbnail_save,
|
||||||
|
thumbnail_get_stats,
|
||||||
|
thumbnail_set_limit,
|
||||||
|
thumbnail_clear_cache,
|
||||||
|
thumbnail_delete_item,
|
||||||
|
image_get_url,
|
||||||
|
// People cache commands
|
||||||
|
storage_save_person,
|
||||||
|
storage_get_person,
|
||||||
|
storage_save_item_people,
|
||||||
|
storage_get_item_people,
|
||||||
|
// Series audio preferences
|
||||||
|
storage_save_series_audio_preference,
|
||||||
|
storage_get_series_audio_preference,
|
||||||
|
// Repository commands
|
||||||
|
repository_create,
|
||||||
|
repository_destroy,
|
||||||
|
repository_get_libraries,
|
||||||
|
repository_get_items,
|
||||||
|
repository_get_item,
|
||||||
|
repository_get_latest_items,
|
||||||
|
repository_get_resume_items,
|
||||||
|
repository_get_next_up_episodes,
|
||||||
|
repository_get_recently_played_audio,
|
||||||
|
repository_get_resume_movies,
|
||||||
|
repository_get_genres,
|
||||||
|
repository_search,
|
||||||
|
repository_get_playback_info,
|
||||||
|
repository_get_video_stream_url,
|
||||||
|
repository_get_audio_stream_url,
|
||||||
|
repository_report_playback_start,
|
||||||
|
repository_report_playback_progress,
|
||||||
|
repository_report_playback_stopped,
|
||||||
|
repository_get_image_url,
|
||||||
|
repository_mark_favorite,
|
||||||
|
repository_unmark_favorite,
|
||||||
|
repository_get_person,
|
||||||
|
repository_get_items_by_person,
|
||||||
|
repository_get_similar_items,
|
||||||
|
// Conversion commands
|
||||||
|
format_time_seconds,
|
||||||
|
format_time_seconds_long,
|
||||||
|
convert_ticks_to_seconds,
|
||||||
|
calc_progress,
|
||||||
|
convert_percent_to_volume,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
jellytau_lib::run()
|
||||||
|
}
|
||||||
757
src-tauri/src/playback_mode/mod.rs
Normal file
@ -0,0 +1,757 @@
|
|||||||
|
use log::{debug, error, info};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Mutex, RwLock,
|
||||||
|
};
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
|
||||||
|
use crate::jellyfin::JellyfinClient;
|
||||||
|
use crate::player::{PlayerController, QueueContext};
|
||||||
|
|
||||||
|
/// Playback mode - local device, remote session, or idle
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum PlaybackMode {
|
||||||
|
Local,
|
||||||
|
Remote { session_id: String },
|
||||||
|
Idle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages playback mode transfers between local and remote sessions
|
||||||
|
pub struct PlaybackModeManager {
|
||||||
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||||
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
||||||
|
current_mode: Arc<RwLock<PlaybackMode>>,
|
||||||
|
is_transferring: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackModeManager {
|
||||||
|
/// Create a new playback mode manager
|
||||||
|
pub fn new(
|
||||||
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||||
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
jellyfin_client,
|
||||||
|
player_controller,
|
||||||
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||||
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current playback mode
|
||||||
|
pub fn get_mode(&self) -> PlaybackMode {
|
||||||
|
self.current_mode.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set playback mode (internal use)
|
||||||
|
pub fn set_mode(&self, mode: PlaybackMode) {
|
||||||
|
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||||
|
let mut current = self.current_mode.write().unwrap();
|
||||||
|
*current = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if currently transferring
|
||||||
|
pub fn is_transferring(&self) -> bool {
|
||||||
|
self.is_transferring.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send volume command to remote session
|
||||||
|
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
||||||
|
#[allow(dead_code)] // Called from Android JNI callback
|
||||||
|
pub async fn send_remote_volume_command(&self, command: &str, volume: i32) -> Result<(), String> {
|
||||||
|
log::info!("[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume);
|
||||||
|
|
||||||
|
// Get the current session ID
|
||||||
|
let session_id = match self.get_mode() {
|
||||||
|
PlaybackMode::Remote { session_id } => session_id,
|
||||||
|
_ => {
|
||||||
|
log::warn!("[PlaybackMode] Ignoring remote volume command - not in remote mode");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("[PlaybackMode] Current mode is Remote, session_id={}", session_id);
|
||||||
|
|
||||||
|
// Get Jellyfin client
|
||||||
|
let client = {
|
||||||
|
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
|
||||||
|
let client_opt = self
|
||||||
|
.jellyfin_client
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||||
|
format!("Failed to lock Jellyfin client: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!("[PlaybackMode] Jellyfin client lock acquired");
|
||||||
|
|
||||||
|
match client_opt.as_ref() {
|
||||||
|
Some(c) => {
|
||||||
|
log::info!("[PlaybackMode] Jellyfin client is configured, cloning...");
|
||||||
|
c.clone()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::error!("[PlaybackMode] Jellyfin client is NOT configured!");
|
||||||
|
return Err("Jellyfin client not configured".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("[PlaybackMode] About to call client.session_set_volume...");
|
||||||
|
|
||||||
|
// Send the volume command
|
||||||
|
log::info!("[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume);
|
||||||
|
let result = client.session_set_volume(session_id, volume).await;
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(_) => log::info!("[PlaybackMode] session_set_volume returned Ok"),
|
||||||
|
Err(e) => log::error!("[PlaybackMode] session_set_volume returned Err: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract Jellyfin item IDs from queue items
|
||||||
|
/// Returns (item_ids, adjusted_current_index)
|
||||||
|
fn extract_jellyfin_ids(&self, items: &[crate::player::MediaItem], original_index: usize) -> Result<(Vec<String>, usize), String> {
|
||||||
|
|
||||||
|
let mut jellyfin_ids: Vec<String> = Vec::new();
|
||||||
|
let mut adjusted_index: Option<usize> = None;
|
||||||
|
let mut jellyfin_item_count = 0;
|
||||||
|
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
if let Some(id) = item.jellyfin_id() {
|
||||||
|
jellyfin_ids.push(id.to_string());
|
||||||
|
|
||||||
|
// If this is the currently playing item, record its new index
|
||||||
|
if i == original_index {
|
||||||
|
adjusted_index = Some(jellyfin_item_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
jellyfin_item_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the currently playing item has a Jellyfin ID
|
||||||
|
let final_index = match adjusted_index {
|
||||||
|
Some(idx) => idx,
|
||||||
|
None => {
|
||||||
|
log::warn!(
|
||||||
|
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
|
||||||
|
original_index
|
||||||
|
);
|
||||||
|
return Err("Cannot transfer: currently playing item is not from Jellyfin".to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Extracted {} Jellyfin IDs from queue (original index: {} -> adjusted: {})",
|
||||||
|
jellyfin_ids.len(),
|
||||||
|
original_index,
|
||||||
|
final_index
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((jellyfin_ids, final_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer playback from local device to remote Jellyfin session
|
||||||
|
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> {
|
||||||
|
debug!("[PlaybackMode] transfer_to_remote ENTERED");
|
||||||
|
debug!("[PlaybackMode] session_id: {}", session_id);
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Transferring to remote session: {}",
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set transferring flag
|
||||||
|
debug!("[PlaybackMode] Setting is_transferring flag");
|
||||||
|
self.is_transferring.store(true, Ordering::Relaxed);
|
||||||
|
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
||||||
|
|
||||||
|
// Perform the transfer
|
||||||
|
let result = self.transfer_to_remote_inner(&session_id).await;
|
||||||
|
|
||||||
|
// Clear transferring flag
|
||||||
|
self.is_transferring.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> {
|
||||||
|
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
||||||
|
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
|
||||||
|
|
||||||
|
// Get current player state and queue context
|
||||||
|
let (queue_ids, current_index, position_seconds, queue_context) = {
|
||||||
|
log::info!("[PlaybackMode] Acquiring player controller lock...");
|
||||||
|
debug!("[PlaybackMode] Acquiring player controller lock...");
|
||||||
|
let player = self.player_controller.lock().await;
|
||||||
|
log::info!("[PlaybackMode] Player controller lock acquired");
|
||||||
|
debug!("[PlaybackMode] Player controller lock acquired");
|
||||||
|
|
||||||
|
let queue_arc = player.queue();
|
||||||
|
let queue = queue_arc.lock().unwrap();
|
||||||
|
let state = player.state();
|
||||||
|
|
||||||
|
let original_index = queue.current_index().unwrap_or(0);
|
||||||
|
let items = queue.items();
|
||||||
|
|
||||||
|
log::info!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
|
||||||
|
debug!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
|
||||||
|
|
||||||
|
// Log each item's jellyfin_id for debugging
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
let jf_id = item.jellyfin_id().unwrap_or("NONE");
|
||||||
|
log::debug!("[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
||||||
|
let position = state.position().unwrap_or(0.0);
|
||||||
|
let context = queue.context().clone();
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Queue context: {:?}, {} items, current index: {}",
|
||||||
|
context,
|
||||||
|
ids.len(),
|
||||||
|
adjusted_index
|
||||||
|
);
|
||||||
|
debug!(
|
||||||
|
"[PlaybackMode] Extracted {} jellyfin IDs, adjusted_index={}, position={:.2}s",
|
||||||
|
ids.len(),
|
||||||
|
adjusted_index,
|
||||||
|
position
|
||||||
|
);
|
||||||
|
|
||||||
|
(ids, adjusted_index, position, context)
|
||||||
|
};
|
||||||
|
|
||||||
|
// If queue is empty, just switch mode
|
||||||
|
if queue_ids.is_empty() {
|
||||||
|
log::info!("[PlaybackMode] Queue is empty, just switching mode");
|
||||||
|
self.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Queue has {} items, current index: {}, position: {:.2}s",
|
||||||
|
queue_ids.len(),
|
||||||
|
current_index,
|
||||||
|
position_seconds
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Jellyfin client for remote transfer
|
||||||
|
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
||||||
|
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
||||||
|
let client = {
|
||||||
|
let client_opt = self
|
||||||
|
.jellyfin_client
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||||
|
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||||
|
format!("Failed to lock Jellyfin client: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match client_opt.as_ref() {
|
||||||
|
Some(c) => {
|
||||||
|
log::info!("[PlaybackMode] Jellyfin client is configured");
|
||||||
|
debug!("[PlaybackMode] Jellyfin client is configured");
|
||||||
|
c.clone()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::error!("[PlaybackMode] Jellyfin client NOT configured!");
|
||||||
|
error!("[PlaybackMode] Jellyfin client NOT configured!");
|
||||||
|
return Err("Jellyfin client not configured".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate position in ticks
|
||||||
|
let start_position_ticks = if position_seconds > 0.5 {
|
||||||
|
Some((position_seconds * 10_000_000.0) as i64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log queue context for debugging (context is tracked but we always send track IDs)
|
||||||
|
match &queue_context {
|
||||||
|
QueueContext::Album { album_id, album_name } => {
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
|
||||||
|
album_name,
|
||||||
|
album_id,
|
||||||
|
queue_ids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
QueueContext::Playlist { playlist_id, playlist_name } => {
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
|
||||||
|
playlist_name,
|
||||||
|
playlist_id,
|
||||||
|
queue_ids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
QueueContext::Custom => {
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Transferring custom queue with {} tracks to remote",
|
||||||
|
queue_ids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always send individual track IDs - Jellyfin's play_on_session expects track IDs,
|
||||||
|
// not album/playlist container IDs
|
||||||
|
let expected_item_id = queue_ids
|
||||||
|
.get(current_index)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("Invalid start index")?;
|
||||||
|
|
||||||
|
// Send play command to remote session with all track IDs
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Sending play command to remote session: {} ({} tracks, starting at index {}, position: {:.2}s)",
|
||||||
|
session_id,
|
||||||
|
queue_ids.len(),
|
||||||
|
current_index,
|
||||||
|
position_seconds
|
||||||
|
);
|
||||||
|
debug!(
|
||||||
|
"[PlaybackMode] Calling play_on_session: session={}, tracks={}, index={}, position_ticks={:?}",
|
||||||
|
session_id,
|
||||||
|
queue_ids.len(),
|
||||||
|
current_index,
|
||||||
|
start_position_ticks
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log first few track IDs for debugging
|
||||||
|
if queue_ids.len() > 0 {
|
||||||
|
let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
|
||||||
|
debug!("[PlaybackMode] First track IDs: {:?}...", preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.play_on_session(
|
||||||
|
session_id.to_string(),
|
||||||
|
queue_ids.clone(),
|
||||||
|
current_index,
|
||||||
|
start_position_ticks,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("[PlaybackMode] Failed to send play command: {}", e);
|
||||||
|
error!("[PlaybackMode] Failed to send play command: {}", e);
|
||||||
|
format!("Failed to start playback on remote session: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!("[PlaybackMode] Play command sent successfully");
|
||||||
|
info!("[PlaybackMode] Play command sent successfully to remote session");
|
||||||
|
|
||||||
|
// Wait for remote session to load the track (poll with timeout)
|
||||||
|
log::info!("[PlaybackMode] Waiting for remote session to load track...");
|
||||||
|
|
||||||
|
let mut attempts = 0;
|
||||||
|
let max_attempts = 50; // 5 seconds max (50 * 100ms)
|
||||||
|
let mut track_loaded = false;
|
||||||
|
|
||||||
|
while attempts < max_attempts {
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
attempts += 1;
|
||||||
|
|
||||||
|
match client.get_session(session_id).await {
|
||||||
|
Ok(Some(session)) => {
|
||||||
|
if let Some(now_playing) = &session.now_playing_item {
|
||||||
|
if now_playing.id.as_deref() == Some(&expected_item_id) {
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Remote session loaded track '{}' after {}ms",
|
||||||
|
now_playing.name.as_deref().unwrap_or("Unknown"),
|
||||||
|
attempts * 100
|
||||||
|
);
|
||||||
|
track_loaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log::warn!("[PlaybackMode] Remote session not found while polling");
|
||||||
|
return Err("Remote session not found".to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e);
|
||||||
|
// Continue polling - transient errors are OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !track_loaded {
|
||||||
|
log::error!("[PlaybackMode] Timeout waiting for remote session to load track");
|
||||||
|
return Err("Remote session did not load track in time".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop local playback (queue should remain intact for remote session)
|
||||||
|
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
|
||||||
|
{
|
||||||
|
let player = self.player_controller.lock().await;
|
||||||
|
|
||||||
|
// Log queue state BEFORE stop
|
||||||
|
{
|
||||||
|
let queue_arc = player.queue();
|
||||||
|
let queue = queue_arc.lock().unwrap();
|
||||||
|
info!(
|
||||||
|
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
|
||||||
|
queue.items().len(),
|
||||||
|
queue.current_index()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?;
|
||||||
|
|
||||||
|
// Log queue state AFTER stop (should be unchanged)
|
||||||
|
{
|
||||||
|
let queue_arc = player.queue();
|
||||||
|
let queue = queue_arc.lock().unwrap();
|
||||||
|
info!(
|
||||||
|
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
|
||||||
|
queue.items().len(),
|
||||||
|
queue.current_index()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mode to remote
|
||||||
|
self.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable remote volume control on Android (intercepts volume buttons)
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||||
|
log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e);
|
||||||
|
// Non-fatal - continue with transfer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[PlaybackMode] Successfully transferred to remote");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer playback from remote session back to local device
|
||||||
|
pub async fn transfer_to_local(
|
||||||
|
&self,
|
||||||
|
current_item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[PlaybackMode] Transferring to local playback");
|
||||||
|
|
||||||
|
// Set transferring flag
|
||||||
|
self.is_transferring.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Perform the transfer
|
||||||
|
let result = self
|
||||||
|
.transfer_to_local_inner(¤t_item_id, position_ticks)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Clear transferring flag
|
||||||
|
self.is_transferring.store(false, Ordering::Relaxed);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transfer_to_local_inner(
|
||||||
|
&self,
|
||||||
|
current_item_id: &str,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Get current remote session info
|
||||||
|
let session_id = match self.get_mode() {
|
||||||
|
PlaybackMode::Remote { session_id } => session_id,
|
||||||
|
_ => return Err("Not in remote playback mode".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let position_seconds = position_ticks as f64 / 10_000_000.0;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Transfer to local: session={}, item_id={}, position={:.2}s",
|
||||||
|
session_id,
|
||||||
|
current_item_id,
|
||||||
|
position_seconds
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Jellyfin client for stopping remote playback
|
||||||
|
let client = {
|
||||||
|
let client_opt = self
|
||||||
|
.jellyfin_client
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("Failed to lock Jellyfin client: {}", e))?;
|
||||||
|
|
||||||
|
client_opt
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("Jellyfin client not configured")?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop remote playback
|
||||||
|
log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id);
|
||||||
|
match client.send_session_command(session_id.clone(), "Stop").await {
|
||||||
|
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e);
|
||||||
|
// Don't fail the transfer if we can't stop the remote session
|
||||||
|
// The user is already playing locally, so this is not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, we'll return an error indicating that the TypeScript side needs to handle
|
||||||
|
// loading the media item, since we don't have access to the repository here yet.
|
||||||
|
// This will be improved in Phase 3 when repository is migrated to Rust.
|
||||||
|
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
|
||||||
|
|
||||||
|
// Update mode to local
|
||||||
|
self.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
|
// Disable remote volume control on Android (return to system volume)
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::disable_remote_volume() {
|
||||||
|
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
||||||
|
// Non-fatal - continue with transfer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[PlaybackMode] Successfully transferred to local");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test PlaybackMode enum serialization to JSON
|
||||||
|
///
|
||||||
|
/// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_playback_mode_serialization() {
|
||||||
|
let mode_idle = PlaybackMode::Idle;
|
||||||
|
let json = serde_json::to_string(&mode_idle).unwrap();
|
||||||
|
assert_eq!(json, r#"{"type":"idle"}"#);
|
||||||
|
|
||||||
|
let mode_local = PlaybackMode::Local;
|
||||||
|
let json = serde_json::to_string(&mode_local).unwrap();
|
||||||
|
assert_eq!(json, r#"{"type":"local"}"#);
|
||||||
|
|
||||||
|
let mode_remote = PlaybackMode::Remote {
|
||||||
|
session_id: "abc123".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&mode_remote).unwrap();
|
||||||
|
assert!(json.contains(r#""type":"remote""#));
|
||||||
|
assert!(json.contains(r#""session_id":"abc123""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test PlaybackMode enum deserialization from JSON
|
||||||
|
///
|
||||||
|
/// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
|
||||||
|
#[test]
|
||||||
|
fn test_playback_mode_deserialization() {
|
||||||
|
let json = r#"{"type":"idle"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(mode, PlaybackMode::Idle);
|
||||||
|
|
||||||
|
let json = r#"{"type":"local"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(mode, PlaybackMode::Local);
|
||||||
|
|
||||||
|
let json = r#"{"type":"remote","session_id":"test_session"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
mode,
|
||||||
|
PlaybackMode::Remote {
|
||||||
|
session_id: "test_session".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
|
||||||
|
mod extract_jellyfin_ids_tests {
|
||||||
|
use crate::player::{MediaItem, MediaSource, MediaType};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
|
||||||
|
MediaItem {
|
||||||
|
id: id.to_string(),
|
||||||
|
title: format!("Track {}", id),
|
||||||
|
name: Some(format!("Track {}", id)),
|
||||||
|
artist: Some("Test Artist".to_string()),
|
||||||
|
album: Some("Test Album".to_string()),
|
||||||
|
album_name: Some("Test Album".to_string()),
|
||||||
|
album_id: Some("album_123".to_string()),
|
||||||
|
artist_items: None,
|
||||||
|
artists: Some(vec!["Test Artist".to_string()]),
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: Some("Audio".to_string()),
|
||||||
|
playlist_id: None,
|
||||||
|
duration: Some(180.0),
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: format!("http://example.com/{}.mp3", id),
|
||||||
|
jellyfin_item_id: jellyfin_id.to_string(),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_test_item_local(id: &str) -> MediaItem {
|
||||||
|
MediaItem {
|
||||||
|
id: id.to_string(),
|
||||||
|
title: format!("Local Track {}", id),
|
||||||
|
name: Some(format!("Local Track {}", id)),
|
||||||
|
artist: Some("Test Artist".to_string()),
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: Some(vec!["Test Artist".to_string()]),
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: Some("Audio".to_string()),
|
||||||
|
playlist_id: None,
|
||||||
|
duration: Some(180.0),
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
source: MediaSource::DirectUrl {
|
||||||
|
url: format!("http://example.com/{}.mp3", id),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test extracting all Jellyfin track IDs from album
|
||||||
|
///
|
||||||
|
/// Verifies that all individual track IDs are extracted, not just the album ID.
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
/// @req-test: DR-003 - Playback mode manager (Jellyfin ID extraction)
|
||||||
|
/// @req-test: IR-012 - Jellyfin Sessions API for remote playback control
|
||||||
|
#[test]
|
||||||
|
fn test_extract_all_jellyfin_ids_from_album() {
|
||||||
|
// Simulate an album with 5 tracks - all should be extracted
|
||||||
|
let items: Vec<MediaItem> = (1..=5)
|
||||||
|
.map(|i| create_test_item_with_jellyfin_id(&format!("track_{}", i), &format!("jf_track_{}", i)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let manager = super::PlaybackModeManager::new(
|
||||||
|
Arc::new(Mutex::new(None)),
|
||||||
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = manager.extract_jellyfin_ids(&items, 2);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let (ids, index) = result.unwrap();
|
||||||
|
|
||||||
|
// All 5 track IDs should be extracted (not just the album ID)
|
||||||
|
assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted");
|
||||||
|
assert_eq!(ids[0], "jf_track_1");
|
||||||
|
assert_eq!(ids[1], "jf_track_2");
|
||||||
|
assert_eq!(ids[2], "jf_track_3");
|
||||||
|
assert_eq!(ids[3], "jf_track_4");
|
||||||
|
assert_eq!(ids[4], "jf_track_5");
|
||||||
|
|
||||||
|
// Index should point to track 3 (original index 2)
|
||||||
|
assert_eq!(index, 2, "Current index should be preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test extracting Jellyfin IDs filters out local items
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of remote sessions (local filtering)
|
||||||
|
/// @req-test: DR-003 - Playback mode manager (local item filtering)
|
||||||
|
#[test]
|
||||||
|
fn test_extract_filters_local_items() {
|
||||||
|
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
|
||||||
|
let items = vec![
|
||||||
|
create_test_item_with_jellyfin_id("1", "jf_1"),
|
||||||
|
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||||
|
create_test_item_with_jellyfin_id("3", "jf_3"),
|
||||||
|
create_test_item_with_jellyfin_id("4", "jf_4"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let manager = super::PlaybackModeManager::new(
|
||||||
|
Arc::new(Mutex::new(None)),
|
||||||
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Playing track 3 (index 2 in original, should become index 1 after filtering)
|
||||||
|
let result = manager.extract_jellyfin_ids(&items, 2);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let (ids, index) = result.unwrap();
|
||||||
|
|
||||||
|
// Only 3 Jellyfin tracks should be extracted
|
||||||
|
assert_eq!(ids.len(), 3);
|
||||||
|
assert_eq!(ids[0], "jf_1");
|
||||||
|
assert_eq!(ids[1], "jf_3");
|
||||||
|
assert_eq!(ids[2], "jf_4");
|
||||||
|
|
||||||
|
// Index should be adjusted (track 3 is now at position 1)
|
||||||
|
assert_eq!(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test extraction fails when current item is local
|
||||||
|
///
|
||||||
|
/// @req-test: DR-003 - Playback mode manager (error handling)
|
||||||
|
/// @req-test: UR-010 - Control playback of remote sessions (validation)
|
||||||
|
#[test]
|
||||||
|
fn test_extract_fails_when_current_item_is_local() {
|
||||||
|
// Current item has no Jellyfin ID - should fail
|
||||||
|
let items = vec![
|
||||||
|
create_test_item_with_jellyfin_id("1", "jf_1"),
|
||||||
|
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||||
|
create_test_item_with_jellyfin_id("3", "jf_3"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let manager = super::PlaybackModeManager::new(
|
||||||
|
Arc::new(Mutex::new(None)),
|
||||||
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Playing the local track (index 1) should fail
|
||||||
|
let result = manager.extract_jellyfin_ids(&items, 1);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not from Jellyfin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test extraction fails on empty queue
|
||||||
|
///
|
||||||
|
/// @req-test: DR-003 - Playback mode manager (edge case: empty queue)
|
||||||
|
#[test]
|
||||||
|
fn test_extract_empty_queue() {
|
||||||
|
let items: Vec<MediaItem> = vec![];
|
||||||
|
|
||||||
|
let manager = super::PlaybackModeManager::new(
|
||||||
|
Arc::new(Mutex::new(None)),
|
||||||
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = manager.extract_jellyfin_ids(&items, 0);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src-tauri/src/playback_reporting/mod.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
pub mod reporter;
|
||||||
|
pub mod throttle;
|
||||||
|
pub mod sync_processor;
|
||||||
|
|
||||||
|
pub use reporter::{PlaybackReporter, PlaybackOperation, PlaybackContext};
|
||||||
|
#[allow(unused_imports)] // Will be used when position updates are hooked
|
||||||
|
pub use throttle::EventThrottler;
|
||||||
|
#[allow(unused_imports)] // Will be used when sync processor is integrated
|
||||||
|
pub use sync_processor::SyncProcessor;
|
||||||
332
src-tauri/src/playback_reporting/reporter.rs
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
//! Playback reporter implementation
|
||||||
|
//!
|
||||||
|
//! This module is fully implemented but not yet integrated with the player.
|
||||||
|
//! Dead code warnings are suppressed until integration is complete.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
use crate::jellyfin::client::JellyfinClient;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||||
|
|
||||||
|
/// Playback context information
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlaybackContext {
|
||||||
|
pub context_type: String, // "container" or "single"
|
||||||
|
pub context_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback operation types
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PlaybackOperation {
|
||||||
|
Start {
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
context: Option<PlaybackContext>,
|
||||||
|
},
|
||||||
|
Progress {
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
is_paused: bool,
|
||||||
|
},
|
||||||
|
Stopped {
|
||||||
|
item_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
},
|
||||||
|
MarkPlayed {
|
||||||
|
item_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main playback reporter that handles dual sync (local DB + server)
|
||||||
|
pub struct PlaybackReporter {
|
||||||
|
db_service: Arc<RusqliteService>,
|
||||||
|
jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
|
||||||
|
user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackReporter {
|
||||||
|
/// Creates a new PlaybackReporter
|
||||||
|
pub fn new(
|
||||||
|
db_service: Arc<RusqliteService>,
|
||||||
|
jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
db_service,
|
||||||
|
jellyfin_client,
|
||||||
|
user_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports a playback operation (dual sync: local DB + server)
|
||||||
|
///
|
||||||
|
/// Always updates local DB first, then attempts server sync if online.
|
||||||
|
/// If server sync fails, operation is queued for retry.
|
||||||
|
pub async fn report(&self, operation: PlaybackOperation, is_online: bool) -> Result<(), String> {
|
||||||
|
log::info!("[PlaybackReporter] Reporting operation: {:?}", operation);
|
||||||
|
|
||||||
|
// Always update local DB first (works offline)
|
||||||
|
self.update_local_db(&operation).await?;
|
||||||
|
|
||||||
|
// If online, attempt server sync
|
||||||
|
if is_online {
|
||||||
|
if let Err(e) = self.sync_to_server(&operation).await {
|
||||||
|
log::warn!("[PlaybackReporter] Server sync failed, queueing: {}", e);
|
||||||
|
self.queue_for_sync(&operation).await?;
|
||||||
|
} else {
|
||||||
|
// Mark as synced on success
|
||||||
|
if let Some(item_id) = self.get_item_id(&operation) {
|
||||||
|
self.mark_synced(&item_id).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::debug!("[PlaybackReporter] Offline - queueing operation");
|
||||||
|
self.queue_for_sync(&operation).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates local database with playback info
|
||||||
|
async fn update_local_db(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||||
|
match operation {
|
||||||
|
PlaybackOperation::Start { item_id, position_ticks, context } => {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
|
||||||
|
playback_context_type, playback_context_id, pending_sync)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, 1)
|
||||||
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||||
|
playback_position_ticks = excluded.playback_position_ticks,
|
||||||
|
last_played_at = excluded.last_played_at,
|
||||||
|
playback_context_type = excluded.playback_context_type,
|
||||||
|
playback_context_id = excluded.playback_context_id,
|
||||||
|
pending_sync = 1",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(self.user_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
QueryParam::Int64(*position_ticks),
|
||||||
|
context.as_ref().map(|c| QueryParam::String(c.context_type.clone())).unwrap_or(QueryParam::Null),
|
||||||
|
context.as_ref().and_then(|c| c.context_id.as_ref()).map(|id| QueryParam::String(id.clone())).unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
log::debug!("[PlaybackReporter] Updated local DB for start: {}", item_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackOperation::Progress { item_id, position_ticks, is_paused: _ } |
|
||||||
|
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
|
||||||
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||||
|
playback_position_ticks = excluded.playback_position_ticks,
|
||||||
|
last_played_at = excluded.last_played_at,
|
||||||
|
pending_sync = 1",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(self.user_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
QueryParam::Int64(*position_ticks),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
log::debug!("[PlaybackReporter] Updated local DB for progress/stop: {}", item_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackOperation::MarkPlayed { item_id } => {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
|
||||||
|
VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, 1)
|
||||||
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||||
|
is_played = 1,
|
||||||
|
play_count = COALESCE(play_count, 0) + 1,
|
||||||
|
last_played_at = CURRENT_TIMESTAMP,
|
||||||
|
pending_sync = 1",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(self.user_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
log::debug!("[PlaybackReporter] Updated local DB for mark played: {}", item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs to Jellyfin server
|
||||||
|
async fn sync_to_server(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||||
|
let client_guard = self.jellyfin_client.lock().await;
|
||||||
|
let client = client_guard.as_ref().ok_or("JellyfinClient not initialized")?;
|
||||||
|
|
||||||
|
match operation {
|
||||||
|
PlaybackOperation::Start { item_id, position_ticks, .. } => {
|
||||||
|
client.report_playback_start(
|
||||||
|
item_id.clone(),
|
||||||
|
*position_ticks,
|
||||||
|
None, // play_session_id
|
||||||
|
).await?;
|
||||||
|
log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackOperation::Progress { item_id, position_ticks, is_paused } => {
|
||||||
|
client.report_playback_progress(
|
||||||
|
item_id.clone(),
|
||||||
|
*position_ticks,
|
||||||
|
*is_paused,
|
||||||
|
None, // play_session_id
|
||||||
|
).await?;
|
||||||
|
log::debug!("[PlaybackReporter] Reported progress to server: {} (paused: {})", item_id, is_paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||||
|
client.report_playback_stopped(
|
||||||
|
item_id.clone(),
|
||||||
|
*position_ticks,
|
||||||
|
None, // play_session_id
|
||||||
|
).await?;
|
||||||
|
log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackOperation::MarkPlayed { item_id } => {
|
||||||
|
// For mark as played, we need to get the item's runtime
|
||||||
|
// For now, report as stopped at max position
|
||||||
|
// TODO: Fetch item runtime from DB or assume 100% completion
|
||||||
|
let max_ticks = i64::MAX; // Temporary - should be actual runtime
|
||||||
|
client.report_playback_stopped(
|
||||||
|
item_id.clone(),
|
||||||
|
max_ticks,
|
||||||
|
None,
|
||||||
|
).await?;
|
||||||
|
log::info!("[PlaybackReporter] Reported mark played to server: {}", item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues operation for later sync
|
||||||
|
async fn queue_for_sync(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||||
|
let (op_name, item_id, payload) = match operation {
|
||||||
|
PlaybackOperation::Start { item_id, position_ticks, context } => {
|
||||||
|
let payload_data = serde_json::json!({
|
||||||
|
"position_ticks": position_ticks,
|
||||||
|
"context_type": context.as_ref().map(|c| &c.context_type),
|
||||||
|
"context_id": context.as_ref().and_then(|c| c.context_id.as_ref()),
|
||||||
|
});
|
||||||
|
("report_playback_start", Some(item_id.clone()), Some(payload_data.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackOperation::Progress { .. } => {
|
||||||
|
// Don't queue progress reports - too frequent
|
||||||
|
// Progress is captured by final stop report
|
||||||
|
log::debug!("[PlaybackReporter] Skipping queue for progress report (too frequent)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||||
|
let payload_data = serde_json::json!({
|
||||||
|
"position_ticks": position_ticks,
|
||||||
|
});
|
||||||
|
("report_playback_stopped", Some(item_id.clone()), Some(payload_data.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackOperation::MarkPlayed { item_id } => {
|
||||||
|
("mark_played", Some(item_id.clone()), None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(self.user_id.clone()),
|
||||||
|
QueryParam::String(op_name.to_string()),
|
||||||
|
item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
log::info!("[PlaybackReporter] Queued operation: {}", op_name);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks an item as synced in the local database
|
||||||
|
async fn mark_synced(&self, item_id: &str) -> Result<(), String> {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE user_data SET pending_sync = 0 WHERE user_id = ? AND item_id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(self.user_id.clone()),
|
||||||
|
QueryParam::String(item_id.to_string()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
log::debug!("[PlaybackReporter] Marked as synced: {}", item_id);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts item_id from operation
|
||||||
|
fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String> {
|
||||||
|
match operation {
|
||||||
|
PlaybackOperation::Start { item_id, .. } |
|
||||||
|
PlaybackOperation::Progress { item_id, .. } |
|
||||||
|
PlaybackOperation::Stopped { item_id, .. } |
|
||||||
|
PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for PlaybackReporter {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
db_service: Arc::clone(&self.db_service),
|
||||||
|
jellyfin_client: Arc::clone(&self.jellyfin_client),
|
||||||
|
user_id: self.user_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Unit tests will be added incrementally as dependencies are mocked
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_debug() {
|
||||||
|
let op = PlaybackOperation::Start {
|
||||||
|
item_id: "item123".to_string(),
|
||||||
|
position_ticks: 1000,
|
||||||
|
context: Some(PlaybackContext {
|
||||||
|
context_type: "container".to_string(),
|
||||||
|
context_id: Some("album456".to_string()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let debug_str = format!("{:?}", op);
|
||||||
|
assert!(debug_str.contains("Start"));
|
||||||
|
assert!(debug_str.contains("item123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_context_clone() {
|
||||||
|
let context = PlaybackContext {
|
||||||
|
context_type: "single".to_string(),
|
||||||
|
context_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = context.clone();
|
||||||
|
assert_eq!(cloned.context_type, "single");
|
||||||
|
assert_eq!(cloned.context_id, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src-tauri/src/playback_reporting/sync_processor.rs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
//! Sync queue processor with retry logic and exponential backoff
|
||||||
|
//!
|
||||||
|
//! This is a placeholder implementation. Full implementation will be added
|
||||||
|
//! when the reporter is integrated. Dead code warnings are suppressed.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
#![allow(unused_imports)]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
use crate::jellyfin::client::JellyfinClient;
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
|
||||||
|
/// Configuration for sync processor
|
||||||
|
pub struct SyncConfig {
|
||||||
|
pub max_retries: u32, // 5
|
||||||
|
pub base_retry_delay_ms: u64, // 1000ms
|
||||||
|
pub batch_size: usize, // 10 items
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SyncConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_retries: 5,
|
||||||
|
base_retry_delay_ms: 1000,
|
||||||
|
batch_size: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync queue processor that handles retry logic with exponential backoff
|
||||||
|
///
|
||||||
|
/// This is a placeholder implementation. Full implementation will be added
|
||||||
|
/// in a subsequent task following the plan.
|
||||||
|
pub struct SyncProcessor {
|
||||||
|
_db_service: Arc<RusqliteService>,
|
||||||
|
_jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
|
||||||
|
_repository: Arc<dyn MediaRepository>,
|
||||||
|
_processing: Arc<TokioMutex<bool>>,
|
||||||
|
_cancelled: Arc<AtomicBool>,
|
||||||
|
_config: SyncConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncProcessor {
|
||||||
|
/// Creates a new SyncProcessor
|
||||||
|
pub fn new(
|
||||||
|
db_service: Arc<RusqliteService>,
|
||||||
|
jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
|
||||||
|
repository: Arc<dyn MediaRepository>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
_db_service: db_service,
|
||||||
|
_jellyfin_client: jellyfin_client,
|
||||||
|
_repository: repository,
|
||||||
|
_processing: Arc::new(TokioMutex::new(false)),
|
||||||
|
_cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
_config: SyncConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the sync processor
|
||||||
|
pub async fn start(&self) -> Result<(), String> {
|
||||||
|
log::info!("[SyncProcessor] Started (placeholder implementation)");
|
||||||
|
// TODO: Implement full processor logic
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the sync processor
|
||||||
|
pub async fn stop(&self) -> Result<(), String> {
|
||||||
|
log::info!("[SyncProcessor] Stopped (placeholder implementation)");
|
||||||
|
// TODO: Implement stop logic
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes the sync queue once
|
||||||
|
pub async fn process_queue(&self) -> Result<(), String> {
|
||||||
|
log::debug!("[SyncProcessor] Processing queue (placeholder)");
|
||||||
|
// TODO: Implement queue processing
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates exponential backoff delay
|
||||||
|
fn _calculate_backoff(&self, retry_count: u32) -> Duration {
|
||||||
|
let delay_ms = self._config.base_retry_delay_ms * 2_u64.pow(retry_count);
|
||||||
|
let max_delay_ms = 10_000; // 10 seconds max
|
||||||
|
Duration::from_millis(delay_ms.min(max_delay_ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_config_default() {
|
||||||
|
let config = SyncConfig::default();
|
||||||
|
assert_eq!(config.max_retries, 5);
|
||||||
|
assert_eq!(config.base_retry_delay_ms, 1000);
|
||||||
|
assert_eq!(config.batch_size, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Re-enable when SyncProcessor is fully implemented
|
||||||
|
// #[test]
|
||||||
|
// fn test_calculate_backoff() {
|
||||||
|
// let config = SyncConfig::default();
|
||||||
|
// let processor = SyncProcessor {
|
||||||
|
// _db_service: Arc::new(unsafe { std::mem::zeroed() }), // Placeholder for test
|
||||||
|
// _jellyfin_client: Arc::new(TokioMutex::new(None)),
|
||||||
|
// _repository: Arc::new(unsafe { std::mem::zeroed() }), // Placeholder for test
|
||||||
|
// _processing: Arc::new(TokioMutex::new(false)),
|
||||||
|
// _cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
// _config: config,
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// // Test exponential backoff: 1s, 2s, 4s, 8s, 10s (capped)
|
||||||
|
// assert_eq!(processor._calculate_backoff(0), Duration::from_millis(1000));
|
||||||
|
// assert_eq!(processor._calculate_backoff(1), Duration::from_millis(2000));
|
||||||
|
// assert_eq!(processor._calculate_backoff(2), Duration::from_millis(4000));
|
||||||
|
// assert_eq!(processor._calculate_backoff(3), Duration::from_millis(8000));
|
||||||
|
// assert_eq!(processor._calculate_backoff(4), Duration::from_millis(10000)); // capped
|
||||||
|
// assert_eq!(processor._calculate_backoff(5), Duration::from_millis(10000)); // capped
|
||||||
|
// }
|
||||||
|
}
|
||||||
175
src-tauri/src/playback_reporting/throttle.rs
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
//! Event throttler for position update reporting
|
||||||
|
//!
|
||||||
|
//! This module is fully implemented but not yet integrated with the player.
|
||||||
|
//! Dead code warnings are suppressed until integration is complete.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Event throttler to prevent spam from frequent position updates.
|
||||||
|
///
|
||||||
|
/// Tracks the last report time for each item and ensures reports are only
|
||||||
|
/// sent at most once per throttle duration (default 30 seconds).
|
||||||
|
pub struct EventThrottler {
|
||||||
|
last_report_time: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
throttle_duration: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventThrottler {
|
||||||
|
/// Creates a new EventThrottler with 30 second default interval
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::with_duration(Duration::from_secs(30))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new EventThrottler with custom interval
|
||||||
|
pub fn with_duration(duration: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
last_report_time: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
throttle_duration: duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if enough time has elapsed since the last report for this item
|
||||||
|
pub fn should_report(&self, item_id: &str) -> bool {
|
||||||
|
let last_times = self.last_report_time.lock().unwrap();
|
||||||
|
|
||||||
|
if let Some(last_time) = last_times.get(item_id) {
|
||||||
|
let elapsed = last_time.elapsed();
|
||||||
|
if elapsed < self.throttle_duration {
|
||||||
|
log::debug!(
|
||||||
|
"[EventThrottler] Skipping report for {}, last reported {:.1}s ago (threshold: {}s)",
|
||||||
|
item_id,
|
||||||
|
elapsed.as_secs_f64(),
|
||||||
|
self.throttle_duration.as_secs()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the item as reported at the current time
|
||||||
|
pub fn mark_reported(&self, item_id: &str) {
|
||||||
|
let mut last_times = self.last_report_time.lock().unwrap();
|
||||||
|
last_times.insert(item_id.to_string(), Instant::now());
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[EventThrottler] Marked {} as reported at {:?}",
|
||||||
|
item_id,
|
||||||
|
Instant::now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all tracked report times
|
||||||
|
pub fn clear(&self) {
|
||||||
|
let mut last_times = self.last_report_time.lock().unwrap();
|
||||||
|
last_times.clear();
|
||||||
|
log::debug!("[EventThrottler] Cleared all tracked report times");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a specific item from tracking
|
||||||
|
pub fn clear_item(&self, item_id: &str) {
|
||||||
|
let mut last_times = self.last_report_time.lock().unwrap();
|
||||||
|
last_times.remove(item_id);
|
||||||
|
log::debug!("[EventThrottler] Cleared tracking for {}", item_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventThrottler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for EventThrottler {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
last_report_time: Arc::clone(&self.last_report_time),
|
||||||
|
throttle_duration: self.throttle_duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_throttler_allows_first_report() {
|
||||||
|
let throttler = EventThrottler::new();
|
||||||
|
assert!(throttler.should_report("item1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_throttler_blocks_immediate_second_report() {
|
||||||
|
let throttler = EventThrottler::new();
|
||||||
|
assert!(throttler.should_report("item1"));
|
||||||
|
throttler.mark_reported("item1");
|
||||||
|
assert!(!throttler.should_report("item1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_throttler_allows_report_after_duration() {
|
||||||
|
let throttler = EventThrottler::with_duration(Duration::from_millis(100));
|
||||||
|
assert!(throttler.should_report("item1"));
|
||||||
|
throttler.mark_reported("item1");
|
||||||
|
assert!(!throttler.should_report("item1"));
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(150));
|
||||||
|
assert!(throttler.should_report("item1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_throttler_handles_multiple_items() {
|
||||||
|
let throttler = EventThrottler::new();
|
||||||
|
assert!(throttler.should_report("item1"));
|
||||||
|
throttler.mark_reported("item1");
|
||||||
|
|
||||||
|
assert!(throttler.should_report("item2"));
|
||||||
|
throttler.mark_reported("item2");
|
||||||
|
|
||||||
|
assert!(!throttler.should_report("item1"));
|
||||||
|
assert!(!throttler.should_report("item2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_throttler_clear() {
|
||||||
|
let throttler = EventThrottler::new();
|
||||||
|
throttler.mark_reported("item1");
|
||||||
|
assert!(!throttler.should_report("item1"));
|
||||||
|
|
||||||
|
throttler.clear();
|
||||||
|
assert!(throttler.should_report("item1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_throttler_clear_item() {
|
||||||
|
let throttler = EventThrottler::new();
|
||||||
|
throttler.mark_reported("item1");
|
||||||
|
throttler.mark_reported("item2");
|
||||||
|
|
||||||
|
assert!(!throttler.should_report("item1"));
|
||||||
|
assert!(!throttler.should_report("item2"));
|
||||||
|
|
||||||
|
throttler.clear_item("item1");
|
||||||
|
assert!(throttler.should_report("item1"));
|
||||||
|
assert!(!throttler.should_report("item2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_throttler_clone_shares_state() {
|
||||||
|
let throttler1 = EventThrottler::new();
|
||||||
|
throttler1.mark_reported("item1");
|
||||||
|
|
||||||
|
let throttler2 = throttler1.clone();
|
||||||
|
assert!(!throttler2.should_report("item1"));
|
||||||
|
|
||||||
|
throttler2.mark_reported("item2");
|
||||||
|
assert!(!throttler1.should_report("item2"));
|
||||||
|
}
|
||||||
|
}
|
||||||