First working POC
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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('/'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// }
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::repository::types::MediaItem;
|
||||
|
||||
/// Autoplay decision result - determines what happens after playback ends
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "action", rename_all = "camelCase")]
|
||||
pub enum AutoplayDecision {
|
||||
/// Stop playback (no next item or timer expired)
|
||||
Stop,
|
||||
/// Advance to next track in queue (for audio/movies)
|
||||
AdvanceToNext,
|
||||
/// Show next episode popup with countdown
|
||||
ShowNextEpisodePopup {
|
||||
current_episode: MediaItem,
|
||||
next_episode: MediaItem,
|
||||
countdown_seconds: u32,
|
||||
auto_advance: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Autoplay settings (controls next episode behavior)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AutoplaySettings {
|
||||
/// Whether autoplay is enabled for next episodes
|
||||
pub enabled: bool,
|
||||
/// Countdown duration in seconds before auto-playing next episode
|
||||
pub countdown_seconds: u32,
|
||||
}
|
||||
|
||||
impl Default for AutoplaySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
countdown_seconds: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoplaySettings {
|
||||
/// Validate and clamp countdown seconds to reasonable range (5-30 seconds)
|
||||
pub fn with_validated_countdown(mut self) -> Self {
|
||||
self.countdown_seconds = self.countdown_seconds.clamp(5, 30);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_autoplay_settings_defaults() {
|
||||
let settings = AutoplaySettings::default();
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.countdown_seconds, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_countdown_validation() {
|
||||
let settings = AutoplaySettings {
|
||||
enabled: true,
|
||||
countdown_seconds: 2, // Too short
|
||||
}
|
||||
.with_validated_countdown();
|
||||
assert_eq!(settings.countdown_seconds, 5); // Clamped to min
|
||||
|
||||
let settings = AutoplaySettings {
|
||||
enabled: true,
|
||||
countdown_seconds: 60, // Too long
|
||||
}
|
||||
.with_validated_countdown();
|
||||
assert_eq!(settings.countdown_seconds, 30); // Clamped to max
|
||||
|
||||
let settings = AutoplaySettings {
|
||||
enabled: true,
|
||||
countdown_seconds: 15, // Valid
|
||||
}
|
||||
.with_validated_countdown();
|
||||
assert_eq!(settings.countdown_seconds, 15); // Unchanged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
use super::media::MediaItem;
|
||||
use super::state::PlayerState;
|
||||
use crate::settings::AudioSettings;
|
||||
|
||||
/// Error type for player operations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlayerError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlayerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlayerError {}
|
||||
|
||||
impl PlayerError {
|
||||
pub fn not_implemented() -> Self {
|
||||
Self {
|
||||
message: "Not implemented".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a playback failure error
|
||||
///
|
||||
/// Only available on Android where ExoPlayer uses it for JNI errors
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn playback_failed<S: Into<String>>(message: S) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Player backend trait - implemented by platform-specific players
|
||||
///
|
||||
/// @req: UR-003 - Play videos
|
||||
/// @req: UR-004 - Play audio uninterrupted
|
||||
/// @req: IR-003 - Integration of libmpv for Linux playback
|
||||
/// @req: IR-004 - Integration of ExoPlayer for Android playback
|
||||
/// @req: DR-004 - PlayerBackend trait for platform-agnostic playback
|
||||
pub trait PlayerBackend: Send + Sync {
|
||||
/// Load a media item for playback
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (load operation)
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
||||
|
||||
/// Start or resume playback
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (play operation)
|
||||
fn play(&mut self) -> Result<(), PlayerError>;
|
||||
|
||||
/// Pause playback
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (pause operation)
|
||||
fn pause(&mut self) -> Result<(), PlayerError>;
|
||||
|
||||
/// Stop playback and unload media
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (stop operation)
|
||||
fn stop(&mut self) -> Result<(), PlayerError>;
|
||||
|
||||
/// Seek to a position in seconds
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (scrub operation)
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
||||
|
||||
/// Set volume (0.0 - 1.0)
|
||||
///
|
||||
/// @req: UR-016 - Change system settings while playing (volume)
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
||||
|
||||
/// Get current playback position in seconds
|
||||
fn position(&self) -> f64;
|
||||
|
||||
/// Get total duration in seconds
|
||||
fn duration(&self) -> Option<f64>;
|
||||
|
||||
/// Get current player state
|
||||
fn state(&self) -> PlayerState;
|
||||
|
||||
/// Get current volume
|
||||
fn volume(&self) -> f32;
|
||||
|
||||
/// Apply audio settings (crossfade, gapless, normalization)
|
||||
///
|
||||
/// @req-partial: UR-031 (Linux only) - Crossfade between audio tracks
|
||||
/// @req-partial: UR-032 (Linux only) - Gapless playback for seamless album listening
|
||||
/// @req-partial: UR-033 (Linux only) - Volume normalization to prevent volume jumps
|
||||
/// @req: DR-034 - Crossfade engine with configurable duration (0-12s)
|
||||
/// @req: DR-035 - Gapless playback between sequential tracks
|
||||
/// @req: DR-036 - Volume normalization with preset levels (Loud/Normal/Quiet)
|
||||
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current audio settings
|
||||
///
|
||||
/// @req: DR-034 - Crossfade engine
|
||||
/// @req: DR-035 - Gapless playback
|
||||
/// @req: DR-036 - Volume normalization
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
AudioSettings::default()
|
||||
}
|
||||
|
||||
/// Set the active audio track by stream index
|
||||
///
|
||||
/// @req-planned: UR-021 - Select audio track for video content
|
||||
/// @req-planned: IR-019 - libmpv audio track selection
|
||||
/// @req-planned: DR-024 - Audio track selection UI in video player
|
||||
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
}
|
||||
|
||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||
///
|
||||
/// @req-planned: UR-020 - Select subtitles for video content
|
||||
/// @req-planned: IR-018 - libmpv subtitle rendering and selection
|
||||
/// @req-planned: DR-023 - Subtitle selection UI in video player
|
||||
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
}
|
||||
}
|
||||
|
||||
/// Null player backend (for testing or when no real player is available)
|
||||
///
|
||||
/// @req: DR-004 - PlayerBackend trait (mock implementation for testing)
|
||||
pub struct NullBackend {
|
||||
state: PlayerState,
|
||||
volume: f32,
|
||||
position: f64,
|
||||
duration: Option<f64>,
|
||||
audio_settings: AudioSettings,
|
||||
}
|
||||
|
||||
impl Default for NullBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl NullBackend {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: PlayerState::Idle,
|
||||
volume: 1.0,
|
||||
position: 0.0,
|
||||
duration: None,
|
||||
audio_settings: AudioSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerBackend for NullBackend {
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
|
||||
self.state = PlayerState::Loading {
|
||||
media: media.clone(),
|
||||
};
|
||||
// Simulate immediate load
|
||||
self.duration = media.duration;
|
||||
self.position = 0.0;
|
||||
self.state = PlayerState::Paused {
|
||||
media: media.clone(),
|
||||
position: 0.0,
|
||||
duration: media.duration.unwrap_or(0.0),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
if let PlayerState::Paused { media, position, duration } = &self.state {
|
||||
self.state = PlayerState::Playing {
|
||||
media: media.clone(),
|
||||
position: *position,
|
||||
duration: *duration,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
if let PlayerState::Playing { media, position, duration } = &self.state {
|
||||
self.state = PlayerState::Paused {
|
||||
media: media.clone(),
|
||||
position: *position,
|
||||
duration: *duration,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
self.state = PlayerState::Idle;
|
||||
self.position = 0.0;
|
||||
self.duration = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
self.position = position;
|
||||
match &mut self.state {
|
||||
PlayerState::Playing { position: pos, .. } => *pos = position,
|
||||
PlayerState::Paused { position: pos, .. } => *pos = position,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
self.volume = volume.clamp(0.0, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.position
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
self.duration
|
||||
}
|
||||
|
||||
fn state(&self) -> PlayerState {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
fn volume(&self) -> f32 {
|
||||
self.volume
|
||||
}
|
||||
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
self.audio_settings = settings.clone().with_crossfade_clamped();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.audio_settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Test NullBackend volume default value
|
||||
///
|
||||
/// @req-test: UT-026 - NullBackend volume default value
|
||||
/// @req-test: DR-004 - PlayerBackend trait
|
||||
#[test]
|
||||
fn test_null_backend_volume_default() {
|
||||
let backend = NullBackend::new();
|
||||
assert_eq!(backend.volume(), 1.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend set volume
|
||||
///
|
||||
/// @req-test: UT-027 - NullBackend set volume
|
||||
/// @req-test: UR-016 - Change system settings while playing (volume)
|
||||
#[test]
|
||||
fn test_null_backend_set_volume() {
|
||||
let mut backend = NullBackend::new();
|
||||
backend.set_volume(0.5).unwrap();
|
||||
assert_eq!(backend.volume(), 0.5);
|
||||
}
|
||||
|
||||
/// Test NullBackend volume clamping (high)
|
||||
///
|
||||
/// @req-test: UT-028 - NullBackend volume clamping (high/low)
|
||||
/// @req-test: UR-016 - Change system settings while playing (volume)
|
||||
#[test]
|
||||
fn test_null_backend_volume_clamping_high() {
|
||||
let mut backend = NullBackend::new();
|
||||
backend.set_volume(1.5).unwrap();
|
||||
assert_eq!(backend.volume(), 1.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend volume clamping (low)
|
||||
///
|
||||
/// @req-test: UT-028 - NullBackend volume clamping (high/low)
|
||||
/// @req-test: UR-016 - Change system settings while playing (volume)
|
||||
#[test]
|
||||
fn test_null_backend_volume_clamping_low() {
|
||||
let mut backend = NullBackend::new();
|
||||
backend.set_volume(-0.5).unwrap();
|
||||
assert_eq!(backend.volume(), 0.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend volume boundary values
|
||||
///
|
||||
/// @req-test: UT-029 - NullBackend volume boundary values
|
||||
/// @req-test: UR-016 - Change system settings while playing (volume)
|
||||
#[test]
|
||||
fn test_null_backend_volume_boundary() {
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
backend.set_volume(0.0).unwrap();
|
||||
assert_eq!(backend.volume(), 0.0);
|
||||
|
||||
backend.set_volume(1.0).unwrap();
|
||||
assert_eq!(backend.volume(), 1.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend audio settings default values
|
||||
///
|
||||
/// @req-test: DR-034 - Crossfade engine
|
||||
/// @req-test: DR-035 - Gapless playback
|
||||
/// @req-test: DR-036 - Volume normalization
|
||||
#[test]
|
||||
fn test_null_backend_audio_settings_default() {
|
||||
let backend = NullBackend::new();
|
||||
let settings = backend.audio_settings();
|
||||
assert_eq!(settings.crossfade_duration, 0.0);
|
||||
assert!(settings.gapless_playback);
|
||||
assert!(!settings.normalize_volume);
|
||||
}
|
||||
|
||||
/// Test NullBackend set audio settings
|
||||
///
|
||||
/// @req-test: DR-034 - Crossfade engine with configurable duration
|
||||
/// @req-test: DR-035 - Gapless playback between sequential tracks
|
||||
/// @req-test: DR-036 - Volume normalization with preset levels
|
||||
#[test]
|
||||
fn test_null_backend_set_audio_settings() {
|
||||
use crate::settings::VolumeLevel;
|
||||
|
||||
let mut backend = NullBackend::new();
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 5.0,
|
||||
gapless_playback: false,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
};
|
||||
|
||||
backend.set_audio_settings(&settings).unwrap();
|
||||
|
||||
let result = backend.audio_settings();
|
||||
assert_eq!(result.crossfade_duration, 5.0);
|
||||
assert!(!result.gapless_playback);
|
||||
assert!(result.normalize_volume);
|
||||
assert_eq!(result.volume_level, VolumeLevel::Loud);
|
||||
}
|
||||
|
||||
/// Test NullBackend audio settings crossfade clamping to 12s max
|
||||
///
|
||||
/// @req-test: DR-034 - Crossfade engine with configurable duration (0-12s)
|
||||
#[test]
|
||||
fn test_null_backend_audio_settings_crossfade_clamping() {
|
||||
let mut backend = NullBackend::new();
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 20.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
backend.set_audio_settings(&settings).unwrap();
|
||||
assert_eq!(backend.audio_settings().crossfade_duration, 12.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend seek updates position
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (scrub operation)
|
||||
/// @req-test: DR-004 - PlayerBackend trait
|
||||
#[test]
|
||||
fn test_null_backend_seek_updates_position() {
|
||||
use crate::player::media::{MediaItem, MediaSource, MediaType};
|
||||
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
// Create a test media item
|
||||
let media = MediaItem {
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_name: Some("Test Album".to_string()),
|
||||
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: "http://example.com/test.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
};
|
||||
|
||||
// Load and play the media
|
||||
backend.load(&media).unwrap();
|
||||
backend.play().unwrap();
|
||||
|
||||
// Verify initial position
|
||||
assert_eq!(backend.position(), 0.0);
|
||||
|
||||
// Seek to 30 seconds
|
||||
backend.seek(30.0).unwrap();
|
||||
assert_eq!(backend.position(), 30.0);
|
||||
|
||||
// Seek to 60 seconds
|
||||
backend.seek(60.0).unwrap();
|
||||
assert_eq!(backend.position(), 60.0);
|
||||
|
||||
// Seek backward
|
||||
backend.seek(15.0).unwrap();
|
||||
assert_eq!(backend.position(), 15.0);
|
||||
}
|
||||
|
||||
/// Test NullBackend seek while paused
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (scrub while paused)
|
||||
/// @req-test: DR-001 - Player state machine (seeking from paused state)
|
||||
#[test]
|
||||
fn test_null_backend_seek_while_paused() {
|
||||
use crate::player::media::{MediaItem, MediaSource, MediaType};
|
||||
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
let media = MediaItem {
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_name: Some("Test Album".to_string()),
|
||||
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: "http://example.com/test.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
};
|
||||
|
||||
// Load media (starts paused)
|
||||
backend.load(&media).unwrap();
|
||||
|
||||
// Verify state is paused
|
||||
assert!(matches!(backend.state(), PlayerState::Paused { .. }));
|
||||
|
||||
// Seek while paused
|
||||
backend.seek(45.0).unwrap();
|
||||
assert_eq!(backend.position(), 45.0);
|
||||
|
||||
// Verify still paused
|
||||
assert!(matches!(backend.state(), PlayerState::Paused { .. }));
|
||||
}
|
||||
|
||||
/// Test NullBackend position updates reflected in state
|
||||
///
|
||||
/// @req-test: DR-001 - Player state machine (position tracking)
|
||||
/// @req-test: UR-005 - Control media playback (position accuracy)
|
||||
#[test]
|
||||
fn test_null_backend_position_updates_in_state() {
|
||||
use crate::player::media::{MediaItem, MediaSource, MediaType};
|
||||
|
||||
let mut backend = NullBackend::new();
|
||||
|
||||
let media = MediaItem {
|
||||
id: "test_media".to_string(),
|
||||
title: "Test Track".to_string(),
|
||||
name: Some("Test Track".to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_name: Some("Test Album".to_string()),
|
||||
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: "http://example.com/test.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
};
|
||||
|
||||
backend.load(&media).unwrap();
|
||||
backend.play().unwrap();
|
||||
|
||||
// Seek to 30 seconds
|
||||
backend.seek(30.0).unwrap();
|
||||
|
||||
// Verify the state reflects the new position
|
||||
if let PlayerState::Playing { position, .. } = backend.state() {
|
||||
assert_eq!(position, 30.0);
|
||||
} else {
|
||||
panic!("Expected Playing state");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Player events for frontend communication via Tauri events.
|
||||
//!
|
||||
//! These events are emitted from the player backend to notify the frontend
|
||||
//! of playback state changes, position updates, etc.
|
||||
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::{MediaSessionType, SleepTimerMode};
|
||||
|
||||
/// Events emitted by the player backend to the frontend via Tauri events.
|
||||
///
|
||||
/// These are distinct from `PlayerEvent` in state.rs, which handles internal
|
||||
/// state machine transitions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PlayerStatusEvent {
|
||||
/// Playback position updated (emitted periodically during playback)
|
||||
PositionUpdate {
|
||||
/// Current position in seconds
|
||||
position: f64,
|
||||
/// Total duration in seconds
|
||||
duration: f64,
|
||||
},
|
||||
/// Player state changed
|
||||
StateChanged {
|
||||
/// New state: "playing", "paused", "stopped", "loading", "idle"
|
||||
state: String,
|
||||
/// ID of the current media item, if any
|
||||
media_id: Option<String>,
|
||||
},
|
||||
/// Media has finished loading and is ready to play
|
||||
MediaLoaded {
|
||||
/// Total duration in seconds
|
||||
duration: f64,
|
||||
},
|
||||
/// Playback has ended naturally (reached end of media)
|
||||
PlaybackEnded,
|
||||
/// Buffering state changed
|
||||
Buffering {
|
||||
/// Buffering progress (0-100)
|
||||
percent: u8,
|
||||
},
|
||||
/// An error occurred during playback
|
||||
Error {
|
||||
/// Error message
|
||||
message: String,
|
||||
/// Whether the error is recoverable
|
||||
recoverable: bool,
|
||||
},
|
||||
/// Volume changed
|
||||
VolumeChanged {
|
||||
/// New volume level (0.0-1.0)
|
||||
volume: f32,
|
||||
/// Whether audio is muted
|
||||
muted: bool,
|
||||
},
|
||||
/// Sleep timer state changed
|
||||
SleepTimerChanged {
|
||||
/// Sleep timer mode
|
||||
mode: SleepTimerMode,
|
||||
/// Remaining seconds (for time-based timer)
|
||||
remaining_seconds: u32,
|
||||
},
|
||||
/// Show next episode popup with countdown
|
||||
ShowNextEpisodePopup {
|
||||
/// Current episode that just finished
|
||||
current_episode: crate::repository::types::MediaItem,
|
||||
/// Next episode to play
|
||||
next_episode: crate::repository::types::MediaItem,
|
||||
/// Countdown duration in seconds
|
||||
countdown_seconds: u32,
|
||||
/// Whether to auto-advance when countdown reaches 0
|
||||
auto_advance: bool,
|
||||
},
|
||||
/// Countdown tick (emitted every second during autoplay countdown)
|
||||
CountdownTick {
|
||||
/// Remaining seconds in countdown
|
||||
remaining_seconds: u32,
|
||||
},
|
||||
/// Queue changed (items added, removed, reordered, or playback mode changed)
|
||||
QueueChanged {
|
||||
/// All items in the queue
|
||||
items: Vec<crate::player::media::MediaItem>,
|
||||
/// Current item index
|
||||
current_index: Option<usize>,
|
||||
/// Whether shuffle is enabled
|
||||
shuffle: bool,
|
||||
/// Current repeat mode
|
||||
repeat: crate::player::queue::RepeatMode,
|
||||
/// Whether there's a next track available
|
||||
has_next: bool,
|
||||
/// Whether there's a previous track available
|
||||
has_previous: bool,
|
||||
},
|
||||
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
|
||||
SessionChanged {
|
||||
/// Current session state
|
||||
session: MediaSessionType,
|
||||
},
|
||||
/// Remote sessions updated (for cast/remote control UI)
|
||||
SessionsUpdated {
|
||||
/// All active controllable sessions from Jellyfin
|
||||
sessions: Vec<crate::jellyfin::client::SessionInfo>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Tauri event name for player status events
|
||||
pub const PLAYER_EVENT_NAME: &str = "player-event";
|
||||
|
||||
/// Trait for emitting player events to the frontend.
|
||||
///
|
||||
/// This abstraction allows backends to emit events without depending
|
||||
/// directly on Tauri, making them easier to test.
|
||||
pub trait PlayerEventEmitter: Send + Sync {
|
||||
/// Emit a player status event to the frontend
|
||||
fn emit(&self, event: PlayerStatusEvent);
|
||||
}
|
||||
|
||||
/// Tauri-based implementation of PlayerEventEmitter.
|
||||
///
|
||||
/// Uses Tauri's `AppHandle::emit()` to broadcast events to all windows.
|
||||
pub struct TauriEventEmitter {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl TauriEventEmitter {
|
||||
/// Create a new TauriEventEmitter with the given app handle.
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerEventEmitter for TauriEventEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) {
|
||||
error!("Failed to emit player event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for event emitters.
|
||||
#[allow(dead_code)]
|
||||
pub type SharedEventEmitter = Arc<dyn PlayerEventEmitter>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
|
||||
/// Test event emitter that captures events for verification
|
||||
pub struct TestEventEmitter {
|
||||
events: Mutex<Vec<PlayerStatusEvent>>,
|
||||
}
|
||||
|
||||
impl TestEventEmitter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn events(&self) -> Vec<PlayerStatusEvent> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.events.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerEventEmitter for TestEventEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_update_serialization() {
|
||||
let event = PlayerStatusEvent::PositionUpdate {
|
||||
position: 30.5,
|
||||
duration: 180.0,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("position_update"));
|
||||
assert!(json.contains("30.5"));
|
||||
assert!(json.contains("180"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_changed_serialization() {
|
||||
let event = PlayerStatusEvent::StateChanged {
|
||||
state: "playing".to_string(),
|
||||
media_id: Some("test-id-123".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("state_changed"));
|
||||
assert!(json.contains("playing"));
|
||||
assert!(json.contains("test-id-123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_changed_no_media_id() {
|
||||
let event = PlayerStatusEvent::StateChanged {
|
||||
state: "idle".to_string(),
|
||||
media_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("state_changed"));
|
||||
assert!(json.contains("idle"));
|
||||
assert!(json.contains("null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_loaded_serialization() {
|
||||
let event = PlayerStatusEvent::MediaLoaded { duration: 245.5 };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("media_loaded"));
|
||||
assert!(json.contains("245.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_playback_ended_serialization() {
|
||||
let event = PlayerStatusEvent::PlaybackEnded;
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("playback_ended"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffering_serialization() {
|
||||
let event = PlayerStatusEvent::Buffering { percent: 75 };
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("buffering"));
|
||||
assert!(json.contains("75"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_serialization() {
|
||||
let event = PlayerStatusEvent::Error {
|
||||
message: "Failed to load media".to_string(),
|
||||
recoverable: true,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("error"));
|
||||
assert!(json.contains("Failed to load media"));
|
||||
assert!(json.contains("true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_changed_serialization() {
|
||||
let event = PlayerStatusEvent::VolumeChanged {
|
||||
volume: 0.75,
|
||||
muted: false,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("volume_changed"));
|
||||
assert!(json.contains("0.75"));
|
||||
assert!(json.contains("false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_emitter_captures_events() {
|
||||
let emitter = TestEventEmitter::new();
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
assert_eq!(emitter.events().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_emitter_multiple_events() {
|
||||
let emitter = TestEventEmitter::new();
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position: 10.0,
|
||||
duration: 100.0,
|
||||
});
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: "paused".to_string(),
|
||||
media_id: None,
|
||||
});
|
||||
assert_eq!(emitter.events().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_emitter_thread_safety() {
|
||||
let emitter = Arc::new(TestEventEmitter::new());
|
||||
let mut handles = vec![];
|
||||
|
||||
for i in 0..10 {
|
||||
let emitter_clone = Arc::clone(&emitter);
|
||||
let handle = thread::spawn(move || {
|
||||
emitter_clone.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position: i as f64,
|
||||
duration: 100.0,
|
||||
});
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(emitter.events().len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_event_emitter() {
|
||||
let emitter: SharedEventEmitter = Arc::new(TestEventEmitter::new());
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
// Verify it compiles and works as a trait object
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Context for the current queue - where did the queue items come from?
|
||||
/// This is used for remote playback transfer to send album/playlist context.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum QueueContext {
|
||||
/// Playing from a specific album
|
||||
Album {
|
||||
album_id: String,
|
||||
album_name: String,
|
||||
},
|
||||
/// Playing from a specific playlist
|
||||
Playlist {
|
||||
playlist_id: String,
|
||||
playlist_name: String,
|
||||
},
|
||||
/// Custom queue (search results, manual queue, etc.)
|
||||
/// Will create a temporary playlist on remote transfer
|
||||
#[default]
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Represents a subtitle track
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SubtitleTrack {
|
||||
/// Stream index in the media source
|
||||
pub index: i32,
|
||||
/// Subtitle URL
|
||||
pub url: String,
|
||||
/// Language code (e.g., "eng", "spa")
|
||||
pub language: Option<String>,
|
||||
/// Display title
|
||||
pub label: Option<String>,
|
||||
/// MIME type (e.g., "text/vtt", "application/x-subrip")
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Represents a media item that can be played
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
/// Unique identifier
|
||||
pub id: String,
|
||||
/// Display title
|
||||
pub title: String,
|
||||
/// Name (alias for title - for frontend compatibility)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Artist name(s) for audio
|
||||
pub artist: Option<String>,
|
||||
/// Album name for audio
|
||||
pub album: Option<String>,
|
||||
/// Album name (alias - for frontend compatibility)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub album_name: Option<String>,
|
||||
/// Album ID (Jellyfin ID) for remote transfer context
|
||||
#[serde(default)]
|
||||
pub album_id: Option<String>,
|
||||
/// Artist items with IDs for clickable links
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
|
||||
/// Artists as array of strings (fallback when artist_items not available)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artists: Option<Vec<String>>,
|
||||
/// Primary image tag for artwork
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Item type (Audio, Movie, Episode, etc.)
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub item_type: Option<String>,
|
||||
/// Playlist ID (Jellyfin ID) for remote transfer context
|
||||
#[serde(default)]
|
||||
pub playlist_id: Option<String>,
|
||||
/// Duration in seconds
|
||||
pub duration: Option<f64>,
|
||||
/// URL or path to artwork image
|
||||
pub artwork_url: Option<String>,
|
||||
/// Type of media
|
||||
pub media_type: MediaType,
|
||||
/// Source of the media
|
||||
pub source: MediaSource,
|
||||
/// Video codec (e.g., "h264", "hevc") for video media
|
||||
#[serde(default)]
|
||||
pub video_codec: Option<String>,
|
||||
/// Whether the video requires server-side transcoding
|
||||
#[serde(default)]
|
||||
pub needs_transcoding: bool,
|
||||
/// Video width in pixels
|
||||
#[serde(default)]
|
||||
pub video_width: Option<u32>,
|
||||
/// Video height in pixels
|
||||
#[serde(default)]
|
||||
pub video_height: Option<u32>,
|
||||
/// Available subtitle tracks
|
||||
#[serde(default)]
|
||||
pub subtitles: Vec<SubtitleTrack>,
|
||||
/// Series ID (for TV show episodes) - used for series audio preferences
|
||||
#[serde(default)]
|
||||
pub series_id: Option<String>,
|
||||
/// Server ID - used for series audio preferences
|
||||
#[serde(default)]
|
||||
pub server_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MediaType {
|
||||
Audio,
|
||||
Video,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum MediaSource {
|
||||
/// Streaming from Jellyfin server
|
||||
Remote {
|
||||
stream_url: String,
|
||||
jellyfin_item_id: String,
|
||||
},
|
||||
/// Downloaded/cached locally
|
||||
Local {
|
||||
file_path: PathBuf,
|
||||
/// Original Jellyfin ID for sync-back
|
||||
jellyfin_item_id: Option<String>,
|
||||
},
|
||||
/// Direct URL (e.g., channel plugins)
|
||||
DirectUrl { url: String },
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
/// Get the Jellyfin item ID if available
|
||||
pub fn jellyfin_id(&self) -> Option<&str> {
|
||||
match &self.source {
|
||||
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id),
|
||||
MediaSource::Local { jellyfin_item_id, .. } => jellyfin_item_id.as_deref(),
|
||||
MediaSource::DirectUrl { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the playback URL or file path
|
||||
///
|
||||
/// Only available on Android where ExoPlayer needs direct URL access
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn playback_url(&self) -> String {
|
||||
match &self.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::Local { file_path, .. } => {
|
||||
file_path.to_string_lossy().to_string()
|
||||
}
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
use log::{debug, error, info, warn};
|
||||
use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
|
||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
use libmpv::Mpv;
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
/// MPV-based player backend for Linux
|
||||
///
|
||||
/// Uses libmpv for audio playback with full control over playback state,
|
||||
/// position tracking, and event handling.
|
||||
pub struct MpvBackend {
|
||||
mpv: Arc<Mpv>,
|
||||
state: Arc<Mutex<InternalState>>,
|
||||
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
|
||||
audio_settings: AudioSettings,
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
last_seek_time: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
struct InternalState {
|
||||
current_media: Option<MediaItem>,
|
||||
volume: f32,
|
||||
}
|
||||
|
||||
/// Detect which audio system is available on the system
|
||||
fn detect_audio_system() -> String {
|
||||
info!("[MpvBackend] Detecting audio system...");
|
||||
|
||||
// Try PulseAudio/PipeWire first (most common on modern Linux)
|
||||
if let Ok(output) = Command::new("pactl").arg("info").output() {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.contains("PipeWire") {
|
||||
info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)");
|
||||
return "pulse".to_string();
|
||||
} else if stdout.contains("PulseAudio") {
|
||||
info!("[MpvBackend] Detected PulseAudio");
|
||||
return "pulse".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try detecting PipeWire directly
|
||||
if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() {
|
||||
if output.status.success() {
|
||||
info!("[MpvBackend] Detected PipeWire");
|
||||
return "pulse".to_string(); // PipeWire works with pulse driver
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ALSA is available
|
||||
if std::path::Path::new("/proc/asound/cards").exists() {
|
||||
info!("[MpvBackend] Falling back to ALSA");
|
||||
return "alsa".to_string();
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
warn!("[MpvBackend] Could not detect audio system, using 'auto'");
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
/// Helper to get stream URL from MediaItem
|
||||
fn get_stream_url(media: &MediaItem) -> String {
|
||||
match &media.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::Local { file_path, .. } => {
|
||||
format!("file://{}", file_path.to_string_lossy())
|
||||
}
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
impl MpvBackend {
|
||||
/// Create a new MPV backend
|
||||
pub fn new(
|
||||
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
) -> Result<Self, PlayerError> {
|
||||
info!("[MpvBackend] Initializing MPV backend...");
|
||||
|
||||
// MPV requires LC_NUMERIC to be set to "C" locale
|
||||
// Set it before initializing MPV, then restore it after
|
||||
use std::ffi::CString;
|
||||
unsafe {
|
||||
let c_locale = CString::new("C").unwrap();
|
||||
libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
|
||||
}
|
||||
|
||||
let mpv = Mpv::new().map_err(|e| PlayerError {
|
||||
message: format!("Failed to initialize MPV: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Detect and configure audio output
|
||||
let audio_driver = detect_audio_system();
|
||||
info!("[MpvBackend] Configuring audio output driver: {}", audio_driver);
|
||||
|
||||
mpv.set_property("ao", audio_driver.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e),
|
||||
})?;
|
||||
|
||||
// Enable verbose logging for audio initialization
|
||||
mpv.set_property("msg-level", "all=warn,ao=debug")
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e);
|
||||
});
|
||||
|
||||
// Configure MPV for audio playback
|
||||
mpv.set_property("audio-display", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV audio-display: {:?}", e),
|
||||
})?;
|
||||
|
||||
mpv.set_property("video", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Set volume to 100% (we'll control via MPV's volume property)
|
||||
mpv.set_property("volume", 100i64)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set initial volume: {:?}", e),
|
||||
})?;
|
||||
|
||||
let state = Arc::new(Mutex::new(InternalState {
|
||||
current_media: None,
|
||||
volume: 1.0,
|
||||
}));
|
||||
|
||||
let backend = MpvBackend {
|
||||
mpv: Arc::new(mpv),
|
||||
state,
|
||||
event_emitter,
|
||||
audio_settings: AudioSettings::default(),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
last_seek_time: Arc::new(AtomicU64::new(0)),
|
||||
};
|
||||
|
||||
// Start event loop in background thread
|
||||
backend.start_event_loop();
|
||||
|
||||
info!("[MpvBackend] Initialized successfully");
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
/// Start the MPV event loop in a background thread
|
||||
fn start_event_loop(&self) {
|
||||
let mpv = self.mpv.clone();
|
||||
let event_emitter = self.event_emitter.clone();
|
||||
let state = self.state.clone();
|
||||
let reporter = self.playback_reporter.clone();
|
||||
let throttler = self.position_throttler.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
info!("[MpvBackend] Event loop started");
|
||||
|
||||
let mut ev_ctx = mpv.create_event_context();
|
||||
ev_ctx.disable_deprecated_events().unwrap_or_else(|e| {
|
||||
error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
|
||||
});
|
||||
|
||||
loop {
|
||||
match ev_ctx.wait_event(1.0) {
|
||||
Some(Ok(event)) => match event {
|
||||
libmpv::events::Event::StartFile => {
|
||||
debug!("[MpvBackend] Starting file");
|
||||
}
|
||||
libmpv::events::Event::FileLoaded => {
|
||||
info!("[MpvBackend] File loaded");
|
||||
|
||||
// Get duration
|
||||
if let Ok(duration) = mpv.get_property::<f64>("duration") {
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||
}
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::PlaybackRestart => {
|
||||
debug!("[MpvBackend] Playback started/resumed");
|
||||
|
||||
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: "playing".to_string(),
|
||||
media_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
||||
// Handle pause state changes
|
||||
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
||||
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: if is_paused { "paused" } else { "playing" }.to_string(),
|
||||
media_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::EndFile(reason) => {
|
||||
debug!("[MpvBackend] End file with reason: {}", reason);
|
||||
|
||||
// Only emit PlaybackEnded for natural track completion (EOF = 0)
|
||||
// Don't emit for Stop (2), Quit (3), Error (4), or other reasons
|
||||
// Constants from MPV_END_FILE_REASON enum: EOF=0, STOP=2, QUIT=3, ERROR=4
|
||||
const MPV_END_FILE_REASON_EOF: u32 = 0;
|
||||
const MPV_END_FILE_REASON_STOP: u32 = 2;
|
||||
const MPV_END_FILE_REASON_QUIT: u32 = 3;
|
||||
const MPV_END_FILE_REASON_ERROR: u32 = 4;
|
||||
|
||||
if reason == MPV_END_FILE_REASON_EOF {
|
||||
debug!("[MpvBackend] Track finished naturally (EOF), emitting PlaybackEnded");
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
} else if reason == MPV_END_FILE_REASON_STOP {
|
||||
debug!("[MpvBackend] Track stopped (loading new track), NOT emitting PlaybackEnded");
|
||||
// Don't emit - user is loading a new track
|
||||
} else if reason == MPV_END_FILE_REASON_QUIT {
|
||||
debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
|
||||
// Don't emit - player is shutting down
|
||||
} else if reason == MPV_END_FILE_REASON_ERROR {
|
||||
warn!("[MpvBackend] Track ended with error, NOT emitting PlaybackEnded");
|
||||
// Don't emit - we should handle errors separately
|
||||
} else {
|
||||
debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
|
||||
}
|
||||
}
|
||||
libmpv::events::Event::Shutdown => {
|
||||
info!("[MpvBackend] Shutdown event received");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
error!("[MpvBackend] Event error: {:?}", e);
|
||||
}
|
||||
None => {
|
||||
// Timeout, continue
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
info!("[MpvBackend] Event loop ended");
|
||||
});
|
||||
|
||||
// Start position update thread
|
||||
let mpv_for_position = self.mpv.clone();
|
||||
let emitter_for_position = self.event_emitter.clone();
|
||||
let state_for_position = self.state.clone();
|
||||
let reporter_for_position = reporter.clone();
|
||||
let throttler_for_position = throttler.clone();
|
||||
let last_seek_time_for_position = self.last_seek_time.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
|
||||
// Get current position and duration
|
||||
// Note: We emit position updates even when paused so scrubbing works
|
||||
if let (Ok(pos), Ok(dur)) = (
|
||||
mpv_for_position.get_property::<f64>("time-pos"),
|
||||
mpv_for_position.get_property::<f64>("duration"),
|
||||
) {
|
||||
// Check if we recently seeked - skip position updates briefly after seeks
|
||||
// to avoid "jumping to zero" visual glitches while MPV is seeking
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
let last_seek = last_seek_time_for_position.load(Ordering::Relaxed);
|
||||
let time_since_seek = now.saturating_sub(last_seek);
|
||||
|
||||
// Skip position updates for 150ms after a seek to let MPV stabilize
|
||||
if time_since_seek < 150 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit position update event (even when paused, for scrubbing)
|
||||
if let Some(emitter) = &emitter_for_position {
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position: pos,
|
||||
duration: dur,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we're playing for progress reporting
|
||||
let is_paused = mpv_for_position.get_property::<bool>("pause").unwrap_or(true);
|
||||
|
||||
// Only report progress to server when playing (not paused)
|
||||
if !is_paused {
|
||||
// Throttled progress reporting (every 30s)
|
||||
let jellyfin_id = {
|
||||
let state = state_for_position.lock().unwrap();
|
||||
state.current_media.as_ref()
|
||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||
};
|
||||
|
||||
if let Some(item_id) = jellyfin_id {
|
||||
if throttler_for_position.should_report(&item_id) {
|
||||
let position_ticks = seconds_to_ticks(pos);
|
||||
let reporter_clone = reporter_for_position.clone();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
// Spawn async task to report progress
|
||||
// Check if we're in a Tokio runtime, otherwise spawn a new thread with its own runtime
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
let reporter_guard = reporter_clone.lock().await;
|
||||
if let Some(reporter_instance) = reporter_guard.as_ref() {
|
||||
let operation = PlaybackOperation::Progress {
|
||||
item_id: item_id_clone.clone(),
|
||||
position_ticks,
|
||||
is_paused: false,
|
||||
};
|
||||
|
||||
match reporter_instance.report(operation, true).await {
|
||||
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
|
||||
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: spawn in a new thread with its own runtime
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
let reporter_guard = reporter_clone.lock().await;
|
||||
if let Some(reporter_instance) = reporter_guard.as_ref() {
|
||||
let operation = PlaybackOperation::Progress {
|
||||
item_id: item_id_clone.clone(),
|
||||
position_ticks,
|
||||
is_paused: false,
|
||||
};
|
||||
|
||||
match reporter_instance.report(operation, true).await {
|
||||
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
|
||||
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
throttler_for_position.mark_reported(&item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerBackend for MpvBackend {
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
|
||||
let stream_url = get_stream_url(media);
|
||||
info!("[MpvBackend] Loading: {} - {}", media.title, stream_url);
|
||||
|
||||
// Update state
|
||||
{
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.current_media = Some(media.clone());
|
||||
}
|
||||
|
||||
// Load the media file
|
||||
self.mpv
|
||||
.command("loadfile", &[&stream_url])
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to load file: {:?}", e),
|
||||
})?;
|
||||
|
||||
debug!("[MpvBackend] Load command sent successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Play command");
|
||||
|
||||
self.mpv
|
||||
.set_property("pause", false)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to play: {:?}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Pause command");
|
||||
|
||||
self.mpv
|
||||
.set_property("pause", true)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to pause: {:?}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Stop command");
|
||||
|
||||
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
|
||||
message: format!("Failed to stop: {:?}", e),
|
||||
})?;
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.current_media = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Seek to {} seconds", position);
|
||||
|
||||
// Record the seek time to suppress position updates briefly
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
self.last_seek_time.store(now, Ordering::Relaxed);
|
||||
|
||||
self.mpv
|
||||
.set_property("time-pos", position)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to seek: {:?}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
let clamped = volume.clamp(0.0, 1.0);
|
||||
debug!("[MpvBackend] Set volume to {}", clamped);
|
||||
|
||||
// MPV expects volume as percentage (0-100)
|
||||
let mpv_volume = volume_to_percent(clamped as f64) as i64;
|
||||
|
||||
self.mpv
|
||||
.set_property("volume", mpv_volume)
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set volume: {:?}", e),
|
||||
})?;
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.volume = clamped;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.mpv
|
||||
.get_property::<f64>("time-pos")
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
self.mpv
|
||||
.get_property::<f64>("duration")
|
||||
.ok()
|
||||
.filter(|d| *d > 0.0)
|
||||
}
|
||||
|
||||
fn state(&self) -> PlayerState {
|
||||
let state = self.state.lock().unwrap();
|
||||
|
||||
if let Some(ref media) = state.current_media {
|
||||
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
|
||||
let position = self.position();
|
||||
let duration = self.duration().unwrap_or(0.0);
|
||||
|
||||
if is_paused {
|
||||
PlayerState::Paused {
|
||||
media: media.clone(),
|
||||
position,
|
||||
duration,
|
||||
}
|
||||
} else {
|
||||
PlayerState::Playing {
|
||||
media: media.clone(),
|
||||
position,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PlayerState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(&self) -> f32 {
|
||||
let state = self.state.lock().unwrap();
|
||||
state.volume
|
||||
}
|
||||
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
info!("[MpvBackend] Applying audio settings");
|
||||
self.audio_settings = settings.clone();
|
||||
|
||||
// Apply gapless playback
|
||||
if settings.gapless_playback {
|
||||
self.mpv
|
||||
.set_property("gapless-audio", "yes")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to enable gapless: {:?}", e),
|
||||
})?;
|
||||
} else {
|
||||
self.mpv
|
||||
.set_property("gapless-audio", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to disable gapless: {:?}", e),
|
||||
})?;
|
||||
}
|
||||
|
||||
// TODO: Implement crossfade via MPV audio filters if needed
|
||||
// TODO: Implement volume normalization if needed
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.audio_settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MpvBackend {
|
||||
fn drop(&mut self) {
|
||||
info!("[MpvBackend] Shutting down");
|
||||
// MPV will be automatically cleaned up
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/// Tests for MpvBackend to prevent regressions
|
||||
///
|
||||
/// These tests are designed to catch common issues like:
|
||||
/// - Tokio runtime panics when spawning async tasks from std::thread
|
||||
/// - Position update thread failures
|
||||
/// - Event emission issues
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
/// Test that simulates the position update thread spawning async tasks
|
||||
/// without a Tokio runtime (the bug we just fixed)
|
||||
#[test]
|
||||
fn test_position_thread_handles_missing_tokio_runtime() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
let success = Arc::new(AtomicBool::new(false));
|
||||
let success_clone = success.clone();
|
||||
|
||||
// Spawn a regular thread (no Tokio runtime)
|
||||
let handle = std::thread::spawn(move || {
|
||||
// This simulates what the position update thread does
|
||||
// It should handle the case where there's no Tokio runtime
|
||||
|
||||
// Try to get the current Tokio runtime handle
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
// We have a runtime, use it
|
||||
handle.spawn(async move {
|
||||
// Async work here
|
||||
});
|
||||
} else {
|
||||
// No runtime, spawn a new thread with its own runtime
|
||||
// This is the fix we applied
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
// Async work here
|
||||
success_clone.store(true, Ordering::SeqCst);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
// Give the spawned thread time to complete
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
assert!(
|
||||
success.load(Ordering::SeqCst),
|
||||
"Should successfully execute async code from std::thread without panicking"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that the Tokio runtime fallback pattern works correctly
|
||||
#[test]
|
||||
fn test_tokio_runtime_fallback_pattern() {
|
||||
let counter = Arc::new(Mutex::new(0));
|
||||
let counter_clone = counter.clone();
|
||||
|
||||
// Spawn from a regular thread (no runtime)
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
// Has runtime (shouldn't happen in this test)
|
||||
handle.spawn(async move {
|
||||
*counter_clone.lock().unwrap() += 1;
|
||||
});
|
||||
} else {
|
||||
// No runtime - use fallback (should happen in this test)
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
*counter_clone.lock().unwrap() += 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
// Wait for async task to complete
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
let count = *counter.lock().unwrap();
|
||||
assert_eq!(count, 1, "Fallback pattern should execute async code successfully");
|
||||
}
|
||||
|
||||
/// Test that position update logic works in a thread
|
||||
#[test]
|
||||
fn test_position_update_in_thread() {
|
||||
use std::time::Duration;
|
||||
|
||||
let positions = Arc::new(Mutex::new(Vec::new()));
|
||||
let positions_clone = positions.clone();
|
||||
|
||||
// Simulate the position update thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
for i in 0..5 {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
// Simulate getting position from player
|
||||
let position = i as f64 * 0.25;
|
||||
|
||||
// Store position (simulating event emission)
|
||||
positions_clone.lock().unwrap().push(position);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let recorded_positions = positions.lock().unwrap();
|
||||
assert_eq!(recorded_positions.len(), 5, "Should have recorded 5 position updates");
|
||||
|
||||
// Verify positions are increasing
|
||||
for (i, pos) in recorded_positions.iter().enumerate() {
|
||||
let expected = i as f64 * 0.25;
|
||||
assert!(
|
||||
(*pos - expected).abs() < 0.001,
|
||||
"Position {} should be close to {}",
|
||||
pos,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test async progress reporting pattern
|
||||
#[tokio::test]
|
||||
async fn test_progress_reporting_with_tokio_mutex() {
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackReporter};
|
||||
|
||||
// Create mock reporter (None for this test)
|
||||
let reporter = Arc::new(TokioMutex::new(None::<PlaybackReporter>));
|
||||
let throttler = Arc::new(EventThrottler::new());
|
||||
|
||||
// Simulate progress reporting
|
||||
let item_id = "test_item_123".to_string();
|
||||
|
||||
// This should not panic even though reporter is None
|
||||
let reporter_guard = reporter.lock().await;
|
||||
if let Some(_reporter_instance) = reporter_guard.as_ref() {
|
||||
// Would report here if reporter was configured
|
||||
} else {
|
||||
// Reporter not configured - this is OK
|
||||
}
|
||||
drop(reporter_guard);
|
||||
|
||||
// Verify throttler works
|
||||
assert!(
|
||||
throttler.should_report(&item_id),
|
||||
"First report should be allowed"
|
||||
);
|
||||
|
||||
throttler.mark_reported(&item_id);
|
||||
|
||||
// Immediate second report should be throttled
|
||||
// (EventThrottler has internal logic for this)
|
||||
}
|
||||
|
||||
/// Test that position updates are emitted even when paused (for scrubbing)
|
||||
/// This is critical for UI responsiveness when seeking while paused
|
||||
#[test]
|
||||
fn test_position_updates_while_paused() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
let update_count = Arc::new(AtomicUsize::new(0));
|
||||
let update_count_clone = update_count.clone();
|
||||
|
||||
// Simulate a position update thread that runs regardless of pause state
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Simulate 5 position updates
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// In the real implementation, we check position from MPV
|
||||
// and emit PositionUpdate events even when paused
|
||||
// This simulates that behavior:
|
||||
let _is_paused = true; // Simulating paused state
|
||||
|
||||
// Key: We DON'T skip the update when paused
|
||||
// This allows scrubbing to work
|
||||
update_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let final_count = update_count.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
final_count, 5,
|
||||
"Position updates should be emitted even when paused (got {} updates)",
|
||||
final_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that progress reporting is skipped when paused
|
||||
/// Progress reporting to the server should only happen during active playback
|
||||
#[test]
|
||||
fn test_progress_reporting_skipped_when_paused() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
let report_count = Arc::new(AtomicUsize::new(0));
|
||||
let report_count_clone = report_count.clone();
|
||||
|
||||
// Simulate the progress reporting logic
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Simulate 5 update cycles
|
||||
for i in 0..5 {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Position updates are emitted (tested separately)
|
||||
// But progress reporting depends on pause state
|
||||
let is_paused = i % 2 == 0; // Alternate between paused and playing
|
||||
|
||||
// Key: Only report when NOT paused
|
||||
if !is_paused {
|
||||
report_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let final_count = report_count.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
final_count, 2,
|
||||
"Progress reporting should only happen when not paused (got {} reports)",
|
||||
final_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that position updates are suppressed briefly after a seek
|
||||
/// This prevents "jumping to zero" visual glitches during seek operations
|
||||
#[test]
|
||||
fn test_position_updates_suppressed_after_seek() {
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
let last_seek_time = Arc::new(AtomicU64::new(0));
|
||||
let last_seek_time_clone = last_seek_time.clone();
|
||||
let update_count = Arc::new(AtomicUsize::new(0));
|
||||
let update_count_clone = update_count.clone();
|
||||
|
||||
// Simulate a seek happening
|
||||
let seek_time = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
last_seek_time.store(seek_time, Ordering::Relaxed);
|
||||
|
||||
// Simulate position update thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Try 2 position updates at 50ms intervals (well within the 150ms window)
|
||||
for _ in 0..2 {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Check if we should suppress updates (within 150ms of seek)
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
let last_seek = last_seek_time_clone.load(Ordering::Relaxed);
|
||||
let time_since_seek = now.saturating_sub(last_seek);
|
||||
|
||||
if time_since_seek < 150 {
|
||||
// Suppress update (don't increment counter)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit update
|
||||
update_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let final_count = update_count.load(Ordering::SeqCst);
|
||||
// With 50ms intervals and 150ms suppression window, updates at 50ms and 100ms
|
||||
// should both be suppressed
|
||||
assert_eq!(
|
||||
final_count, 0,
|
||||
"Position updates should be suppressed within 150ms of seek (got {} updates)",
|
||||
final_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that position updates resume after seek suppression window
|
||||
#[test]
|
||||
fn test_position_updates_resume_after_seek_window() {
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
let last_seek_time = Arc::new(AtomicU64::new(0));
|
||||
let last_seek_time_clone = last_seek_time.clone();
|
||||
let update_count = Arc::new(AtomicUsize::new(0));
|
||||
let update_count_clone = update_count.clone();
|
||||
|
||||
// Simulate a seek that happened 200ms ago (past the suppression window)
|
||||
let seek_time = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64
|
||||
- 200; // 200ms ago
|
||||
last_seek_time.store(seek_time, Ordering::Relaxed);
|
||||
|
||||
// Simulate position update thread
|
||||
let handle = std::thread::spawn(move || {
|
||||
// Try 3 position updates
|
||||
for _ in 0..3 {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
// Check if we should suppress updates (within 150ms of seek)
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
let last_seek = last_seek_time_clone.load(Ordering::Relaxed);
|
||||
let time_since_seek = now.saturating_sub(last_seek);
|
||||
|
||||
if time_since_seek < 150 {
|
||||
continue; // Should not happen in this test
|
||||
}
|
||||
|
||||
// Emit update
|
||||
update_count_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
handle.join().unwrap();
|
||||
|
||||
let final_count = update_count.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
final_count, 3,
|
||||
"Position updates should resume after seek suppression window (got {} updates)",
|
||||
final_count
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
use rand::seq::SliceRandom;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::media::{MediaItem, MediaSource, QueueContext};
|
||||
|
||||
/// Repeat mode for the queue
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (repeat mode)
|
||||
/// @req: DR-005 - Queue manager with shuffle, repeat, history
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RepeatMode {
|
||||
#[default]
|
||||
Off,
|
||||
All,
|
||||
One,
|
||||
}
|
||||
|
||||
/// Queue manager for playlist functionality
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (queue navigation)
|
||||
/// @req: UR-015 - View and manage current audio queue (add, reorder tracks)
|
||||
/// @req: DR-005 - Queue manager with shuffle, repeat, history
|
||||
/// @req: DR-020 - Queue management UI (add, remove, reorder)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueueManager {
|
||||
/// All items in the queue
|
||||
items: Vec<MediaItem>,
|
||||
/// Current item index
|
||||
current_index: Option<usize>,
|
||||
/// Whether shuffle is enabled
|
||||
shuffle: bool,
|
||||
/// Current repeat mode
|
||||
repeat: RepeatMode,
|
||||
/// Shuffled order of indices (used when shuffle is on)
|
||||
shuffle_order: Vec<usize>,
|
||||
/// History of played indices (for going back with shuffle)
|
||||
history: Vec<usize>,
|
||||
/// Context for the queue (album, playlist, or custom)
|
||||
/// Used for remote playback transfer to maintain album/playlist context
|
||||
#[serde(default)]
|
||||
context: QueueContext,
|
||||
}
|
||||
|
||||
impl Default for QueueManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
current_index: None,
|
||||
shuffle: false,
|
||||
repeat: RepeatMode::Off,
|
||||
shuffle_order: Vec::new(),
|
||||
history: Vec::new(),
|
||||
context: QueueContext::Custom,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all items in the queue
|
||||
pub fn items(&self) -> &[MediaItem] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// Get the current item index
|
||||
pub fn current_index(&self) -> Option<usize> {
|
||||
self.current_index
|
||||
}
|
||||
|
||||
/// Get the current item
|
||||
pub fn current(&self) -> Option<&MediaItem> {
|
||||
self.current_index.and_then(|i| self.items.get(i))
|
||||
}
|
||||
|
||||
/// Check if shuffle is enabled
|
||||
pub fn is_shuffle(&self) -> bool {
|
||||
self.shuffle
|
||||
}
|
||||
|
||||
/// Get the current repeat mode
|
||||
pub fn repeat_mode(&self) -> RepeatMode {
|
||||
self.repeat
|
||||
}
|
||||
|
||||
/// Get the current queue context (album, playlist, or custom)
|
||||
pub fn context(&self) -> &QueueContext {
|
||||
&self.context
|
||||
}
|
||||
|
||||
/// Set the queue context
|
||||
pub fn set_context(&mut self, context: QueueContext) {
|
||||
self.context = context;
|
||||
}
|
||||
|
||||
/// Set the queue with new items (resets context to Custom)
|
||||
pub fn set_queue(&mut self, items: Vec<MediaItem>, start_index: usize) {
|
||||
self.set_queue_with_context(items, start_index, QueueContext::Custom);
|
||||
}
|
||||
|
||||
/// Set the queue with new items and explicit context
|
||||
pub fn set_queue_with_context(
|
||||
&mut self,
|
||||
items: Vec<MediaItem>,
|
||||
start_index: usize,
|
||||
context: QueueContext,
|
||||
) {
|
||||
let start_index = start_index.min(items.len().saturating_sub(1));
|
||||
|
||||
if self.shuffle && !items.is_empty() {
|
||||
self.shuffle_order = self.generate_shuffle_order(items.len(), Some(start_index));
|
||||
} else {
|
||||
self.shuffle_order.clear();
|
||||
}
|
||||
|
||||
self.items = items;
|
||||
self.current_index = if self.items.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(start_index)
|
||||
};
|
||||
self.history.clear();
|
||||
self.context = context;
|
||||
}
|
||||
|
||||
/// Add items to the queue
|
||||
pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let insert_index = match position {
|
||||
AddPosition::Next => self.current_index.map(|i| i + 1).unwrap_or(self.items.len()),
|
||||
AddPosition::End => self.items.len(),
|
||||
};
|
||||
|
||||
// Insert items
|
||||
for (i, item) in items.into_iter().enumerate() {
|
||||
self.items.insert(insert_index + i, item);
|
||||
}
|
||||
|
||||
// Update current index if needed
|
||||
if let Some(current) = self.current_index {
|
||||
if insert_index <= current {
|
||||
self.current_index = Some(current + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate shuffle order if shuffle is on
|
||||
if self.shuffle {
|
||||
self.shuffle_order = self.generate_shuffle_order(
|
||||
self.items.len(),
|
||||
self.current_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an item from the queue
|
||||
pub fn remove(&mut self, index: usize) -> Option<MediaItem> {
|
||||
if index >= self.items.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let removed = self.items.remove(index);
|
||||
|
||||
// Update current index
|
||||
if let Some(current) = self.current_index {
|
||||
if index < current {
|
||||
self.current_index = Some(current - 1);
|
||||
} else if index == current {
|
||||
self.current_index = if self.items.is_empty() {
|
||||
None
|
||||
} else if index >= self.items.len() {
|
||||
Some(self.items.len() - 1)
|
||||
} else {
|
||||
Some(index)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update shuffle order
|
||||
if self.shuffle {
|
||||
self.shuffle_order = self.shuffle_order
|
||||
.iter()
|
||||
.filter(|&&i| i != index)
|
||||
.map(|&i| if i > index { i - 1 } else { i })
|
||||
.collect();
|
||||
}
|
||||
|
||||
Some(removed)
|
||||
}
|
||||
|
||||
/// Move to the next item
|
||||
pub fn next(&mut self) -> Option<&MediaItem> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current = self.current_index?;
|
||||
|
||||
let next_index = if self.repeat == RepeatMode::One {
|
||||
// Repeat current track
|
||||
current
|
||||
} else if self.shuffle {
|
||||
// Find current position in shuffle order and get next
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current)?;
|
||||
if pos + 1 < self.shuffle_order.len() {
|
||||
self.shuffle_order[pos + 1]
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
// Wrap around to beginning of shuffle
|
||||
self.shuffle_order[0]
|
||||
} else {
|
||||
log::debug!("[Queue] next() at end of shuffle order, no next track");
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
// Normal sequential order
|
||||
if current + 1 < self.items.len() {
|
||||
current + 1
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
0
|
||||
} else {
|
||||
log::debug!("[Queue] next() at end of queue (index {}), no next track", current);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Only add to history if we're actually changing position
|
||||
if next_index != current {
|
||||
self.history.push(current);
|
||||
log::debug!("[Queue] next() moving: {} -> {}", current, next_index);
|
||||
} else {
|
||||
log::debug!("[Queue] next() repeat one, staying at index {}", current);
|
||||
}
|
||||
|
||||
self.current_index = Some(next_index);
|
||||
self.items.get(next_index)
|
||||
}
|
||||
|
||||
/// Move to the previous item
|
||||
pub fn previous(&mut self) -> Option<&MediaItem> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current = self.current_index?;
|
||||
|
||||
// If we have history, go back in history
|
||||
// But validate it to prevent wraparound bugs
|
||||
if let Some(prev) = self.history.pop() {
|
||||
// Safety check: ensure the history entry is valid
|
||||
if prev >= self.items.len() {
|
||||
log::warn!("[Queue] Invalid history entry {} (queue has {} items), clearing history",
|
||||
prev, self.items.len());
|
||||
self.history.clear();
|
||||
return None;
|
||||
}
|
||||
|
||||
// In non-shuffle mode, previous track should be before current (or this is from a skip_to)
|
||||
// This prevents going from first track to last track
|
||||
if !self.shuffle && prev >= current {
|
||||
log::warn!("[Queue] Suspicious history: going from index {} to {} (non-shuffle mode), clearing history",
|
||||
current, prev);
|
||||
self.history.clear();
|
||||
return None;
|
||||
}
|
||||
|
||||
log::debug!("[Queue] previous() using history: {} -> {}", current, prev);
|
||||
self.current_index = Some(prev);
|
||||
return self.items.get(prev);
|
||||
}
|
||||
|
||||
log::debug!("[Queue] previous() no history, current={}", current);
|
||||
|
||||
let prev_index = if self.shuffle {
|
||||
// In shuffle mode without history, go to previous in shuffle order
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current)?;
|
||||
if pos > 0 {
|
||||
self.shuffle_order[pos - 1]
|
||||
} else {
|
||||
log::debug!("[Queue] previous() at start of shuffle order, staying at current");
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
// Normal sequential order
|
||||
if current > 0 {
|
||||
current - 1
|
||||
} else {
|
||||
log::debug!("[Queue] previous() at index 0, staying at current");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("[Queue] previous() moving: {} -> {}", current, prev_index);
|
||||
self.current_index = Some(prev_index);
|
||||
self.items.get(prev_index)
|
||||
}
|
||||
|
||||
/// Skip to a specific index
|
||||
pub fn skip_to(&mut self, index: usize) -> Option<&MediaItem> {
|
||||
if index >= self.items.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(current) = self.current_index {
|
||||
self.history.push(current);
|
||||
}
|
||||
|
||||
self.current_index = Some(index);
|
||||
self.items.get(index)
|
||||
}
|
||||
|
||||
/// Toggle shuffle mode
|
||||
pub fn toggle_shuffle(&mut self) {
|
||||
self.shuffle = !self.shuffle;
|
||||
|
||||
if self.shuffle && !self.items.is_empty() {
|
||||
self.shuffle_order = self.generate_shuffle_order(
|
||||
self.items.len(),
|
||||
self.current_index,
|
||||
);
|
||||
} else {
|
||||
self.shuffle_order.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle through repeat modes
|
||||
pub fn cycle_repeat(&mut self) {
|
||||
self.repeat = match self.repeat {
|
||||
RepeatMode::Off => RepeatMode::All,
|
||||
RepeatMode::All => RepeatMode::One,
|
||||
RepeatMode::One => RepeatMode::Off,
|
||||
};
|
||||
}
|
||||
|
||||
/// Set repeat mode directly (for testing)
|
||||
#[cfg(test)]
|
||||
pub fn set_repeat(&mut self, mode: RepeatMode) {
|
||||
self.repeat = mode;
|
||||
}
|
||||
|
||||
/// Check if there's a next item available
|
||||
pub fn has_next(&self) -> bool {
|
||||
if self.items.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self.current_index {
|
||||
None => false,
|
||||
Some(current) => {
|
||||
if self.repeat == RepeatMode::All || self.repeat == RepeatMode::One {
|
||||
true
|
||||
} else if self.shuffle {
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current);
|
||||
pos.map(|p| p + 1 < self.shuffle_order.len()).unwrap_or(false)
|
||||
} else {
|
||||
current + 1 < self.items.len()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if there's a previous item available
|
||||
pub fn has_previous(&self) -> bool {
|
||||
!self.history.is_empty() || {
|
||||
match self.current_index {
|
||||
None => false,
|
||||
Some(current) => {
|
||||
if self.shuffle {
|
||||
let pos = self.shuffle_order.iter().position(|&i| i == current);
|
||||
pos.map(|p| p > 0).unwrap_or(false)
|
||||
} else {
|
||||
current > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next N upcoming items (for preloading)
|
||||
/// Returns items that will play after the current item, respecting shuffle order
|
||||
pub fn get_upcoming(&self, count: usize) -> Vec<&MediaItem> {
|
||||
if self.items.is_empty() || count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let current = match self.current_index {
|
||||
Some(idx) => idx,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut upcoming = Vec::with_capacity(count);
|
||||
|
||||
if self.shuffle {
|
||||
// Find current position in shuffle order
|
||||
if let Some(pos) = self.shuffle_order.iter().position(|&i| i == current) {
|
||||
for i in 1..=count {
|
||||
let next_pos = pos + i;
|
||||
if next_pos < self.shuffle_order.len() {
|
||||
if let Some(item) = self.items.get(self.shuffle_order[next_pos]) {
|
||||
upcoming.push(item);
|
||||
}
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
// Wrap around if repeat all is enabled
|
||||
let wrapped_pos = (next_pos) % self.shuffle_order.len();
|
||||
if let Some(item) = self.items.get(self.shuffle_order[wrapped_pos]) {
|
||||
upcoming.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal sequential order
|
||||
for i in 1..=count {
|
||||
let next_idx = current + i;
|
||||
if next_idx < self.items.len() {
|
||||
upcoming.push(&self.items[next_idx]);
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
// Wrap around if repeat all is enabled
|
||||
let wrapped_idx = next_idx % self.items.len();
|
||||
upcoming.push(&self.items[wrapped_idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upcoming
|
||||
}
|
||||
|
||||
/// Move an item from one index to another
|
||||
pub fn move_item(&mut self, from_index: usize, to_index: usize) -> bool {
|
||||
if from_index >= self.items.len() || to_index >= self.items.len() || from_index == to_index
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove the item and insert at new position
|
||||
let item = self.items.remove(from_index);
|
||||
self.items.insert(to_index, item);
|
||||
|
||||
// Update current_index if affected
|
||||
if let Some(current) = self.current_index {
|
||||
if current == from_index {
|
||||
// The moved item was the current one
|
||||
self.current_index = Some(to_index);
|
||||
} else if from_index < current && to_index >= current {
|
||||
// Item moved from before current to after/at current
|
||||
self.current_index = Some(current - 1);
|
||||
} else if from_index > current && to_index <= current {
|
||||
// Item moved from after current to before/at current
|
||||
self.current_index = Some(current + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Update shuffle order if shuffle is on
|
||||
if self.shuffle && !self.shuffle_order.is_empty() {
|
||||
// Regenerate shuffle order to maintain consistency
|
||||
self.shuffle_order =
|
||||
self.generate_shuffle_order(self.items.len(), self.current_index);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Update the stream URL of the current item (for transcoded seeking)
|
||||
/// Returns true if the update was successful
|
||||
pub fn update_current_stream_url(&mut self, new_url: String) -> bool {
|
||||
if let Some(current_index) = self.current_index {
|
||||
if let Some(item) = self.items.get_mut(current_index) {
|
||||
// Only update if it's a Remote source
|
||||
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
|
||||
item.source = MediaSource::Remote {
|
||||
stream_url: new_url,
|
||||
jellyfin_item_id: jellyfin_item_id.clone(),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Generate a shuffle order, optionally starting from a specific index
|
||||
fn generate_shuffle_order(&self, length: usize, start_index: Option<usize>) -> Vec<usize> {
|
||||
let mut indices: Vec<usize> = (0..length).collect();
|
||||
let mut rng = rand::thread_rng();
|
||||
indices.shuffle(&mut rng);
|
||||
|
||||
// Move start index to the front if specified
|
||||
if let Some(start) = start_index {
|
||||
if let Some(pos) = indices.iter().position(|&i| i == start) {
|
||||
indices.remove(pos);
|
||||
indices.insert(0, start);
|
||||
}
|
||||
}
|
||||
|
||||
indices
|
||||
}
|
||||
}
|
||||
|
||||
/// Position to add items to the queue
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddPosition {
|
||||
/// Add immediately after current item
|
||||
Next,
|
||||
/// Add at the end of the queue
|
||||
End,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::media::{MediaSource, MediaType};
|
||||
|
||||
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
||||
(0..count)
|
||||
.map(|i| MediaItem {
|
||||
id: format!("item_{}", i),
|
||||
title: format!("Track {}", i + 1),
|
||||
name: Some(format!("Track {}", i + 1)),
|
||||
artist: Some("Artist".to_string()),
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: Some(vec!["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/track_{}.mp3", i),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Test setting up the queue with items
|
||||
///
|
||||
/// @req-test: UR-015 - View and manage audio queue (add tracks)
|
||||
/// @req-test: DR-005 - Queue manager with shuffle, repeat, history
|
||||
#[test]
|
||||
fn test_set_queue() {
|
||||
let mut queue = QueueManager::new();
|
||||
let items = create_test_items(5);
|
||||
|
||||
queue.set_queue(items.clone(), 0);
|
||||
|
||||
assert_eq!(queue.items().len(), 5);
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
}
|
||||
|
||||
/// Test next track navigation
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (skip to next track)
|
||||
/// @req-test: DR-005 - Queue manager with shuffle, repeat, history
|
||||
#[test]
|
||||
fn test_next() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
|
||||
queue.next();
|
||||
assert_eq!(queue.current().unwrap().id, "item_1");
|
||||
|
||||
queue.next();
|
||||
assert_eq!(queue.current().unwrap().id, "item_2");
|
||||
|
||||
// No next without repeat
|
||||
assert!(queue.next().is_none());
|
||||
}
|
||||
|
||||
/// Test repeat all mode wraps to beginning
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (repeat all mode)
|
||||
/// @req-test: DR-005 - Queue manager with repeat
|
||||
#[test]
|
||||
fn test_repeat_all() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(2), 0);
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
|
||||
queue.next(); // Move to item_1
|
||||
let next = queue.next(); // Should wrap to item_0
|
||||
|
||||
assert!(next.is_some());
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
}
|
||||
|
||||
/// Test previous track navigation
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (previous track)
|
||||
/// @req-test: DR-005 - Queue manager
|
||||
#[test]
|
||||
fn test_previous() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 2);
|
||||
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
}
|
||||
|
||||
/// Test viewing upcoming tracks in queue
|
||||
///
|
||||
/// @req-test: UR-015 - View and manage audio queue
|
||||
/// @req-test: DR-020 - Queue management UI (upcoming tracks)
|
||||
#[test]
|
||||
fn test_get_upcoming() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 0);
|
||||
|
||||
// Get next 3 items
|
||||
let upcoming = queue.get_upcoming(3);
|
||||
assert_eq!(upcoming.len(), 3);
|
||||
assert_eq!(upcoming[0].id, "item_1");
|
||||
assert_eq!(upcoming[1].id, "item_2");
|
||||
assert_eq!(upcoming[2].id, "item_3");
|
||||
|
||||
// Move to item 2 and get upcoming
|
||||
queue.next();
|
||||
queue.next();
|
||||
let upcoming = queue.get_upcoming(3);
|
||||
assert_eq!(upcoming.len(), 2); // Only 2 items remaining
|
||||
assert_eq!(upcoming[0].id, "item_3");
|
||||
assert_eq!(upcoming[1].id, "item_4");
|
||||
}
|
||||
|
||||
/// Test upcoming tracks with repeat all mode
|
||||
///
|
||||
/// @req-test: UR-015 - View and manage audio queue
|
||||
/// @req-test: DR-005 - Queue manager with repeat
|
||||
#[test]
|
||||
fn test_get_upcoming_with_repeat() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 1);
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
|
||||
// At item_1, get upcoming with repeat
|
||||
let upcoming = queue.get_upcoming(4);
|
||||
assert_eq!(upcoming.len(), 4);
|
||||
assert_eq!(upcoming[0].id, "item_2"); // Next
|
||||
assert_eq!(upcoming[1].id, "item_0"); // Wrapped
|
||||
assert_eq!(upcoming[2].id, "item_1"); // Wrapped (current again)
|
||||
assert_eq!(upcoming[3].id, "item_2"); // Wrapped
|
||||
}
|
||||
|
||||
/// Test upcoming tracks on empty queue
|
||||
///
|
||||
/// @req-test: DR-005 - Queue manager (edge case: empty queue)
|
||||
#[test]
|
||||
fn test_get_upcoming_empty() {
|
||||
let queue = QueueManager::new();
|
||||
let upcoming = queue.get_upcoming(3);
|
||||
assert!(upcoming.is_empty());
|
||||
}
|
||||
|
||||
/// Test next stops at end without repeat mode
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (queue end behavior)
|
||||
/// @req-test: DR-005 - Queue manager
|
||||
#[test]
|
||||
fn test_next_no_repeat_reaches_end() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
queue.set_repeat(RepeatMode::Off);
|
||||
|
||||
// At item 0, should have next
|
||||
assert!(queue.has_next());
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
|
||||
// Move to item 1
|
||||
queue.next();
|
||||
assert!(queue.has_next());
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
|
||||
// Move to item 2 (last)
|
||||
queue.next();
|
||||
assert!(!queue.has_next()); // No more items
|
||||
assert_eq!(queue.current_index(), Some(2));
|
||||
|
||||
// Try to move past end
|
||||
let result = queue.next();
|
||||
assert!(result.is_none());
|
||||
assert_eq!(queue.current_index(), Some(2)); // Should stay at last item
|
||||
}
|
||||
|
||||
/// Test next wraps to beginning with repeat all
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (repeat all wrapping)
|
||||
/// @req-test: DR-005 - Queue manager with repeat
|
||||
#[test]
|
||||
fn test_next_repeat_all_wraps() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
|
||||
// With repeat all, has_next should always be true
|
||||
assert!(queue.has_next());
|
||||
|
||||
// Move through all items
|
||||
queue.next(); // item_1
|
||||
assert!(queue.has_next());
|
||||
queue.next(); // item_2
|
||||
assert!(queue.has_next());
|
||||
|
||||
// Wrap to beginning
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
assert!(queue.has_next()); // Still has next (loops forever)
|
||||
}
|
||||
|
||||
/// Test next repeats same track with repeat one mode
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (repeat one mode)
|
||||
/// @req-test: DR-005 - Queue manager with repeat
|
||||
#[test]
|
||||
fn test_next_repeat_one_stays() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 1);
|
||||
queue.set_repeat(RepeatMode::One);
|
||||
|
||||
// Should always have next (repeats current)
|
||||
assert!(queue.has_next());
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
|
||||
// Call next multiple times - should stay on same track
|
||||
for _ in 0..5 {
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(queue.current().unwrap().id, "item_1");
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
assert!(queue.has_next());
|
||||
}
|
||||
}
|
||||
|
||||
/// Test shuffle mode follows randomized order
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (shuffle mode)
|
||||
/// @req-test: DR-005 - Queue manager with shuffle
|
||||
#[test]
|
||||
fn test_next_shuffle_follows_order() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(4), 0);
|
||||
queue.toggle_shuffle(); // Enable shuffle
|
||||
|
||||
// Get the shuffle order for verification
|
||||
let shuffle_order = queue.shuffle_order.clone();
|
||||
assert_eq!(shuffle_order.len(), 4);
|
||||
|
||||
// Current should be first item in shuffle order
|
||||
let first_shuffled_index = shuffle_order[0];
|
||||
assert_eq!(queue.current_index(), Some(first_shuffled_index));
|
||||
|
||||
// Move through shuffle order
|
||||
for i in 1..shuffle_order.len() {
|
||||
assert!(queue.has_next());
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
let expected_index = shuffle_order[i];
|
||||
assert_eq!(queue.current_index(), Some(expected_index));
|
||||
}
|
||||
|
||||
// At end of shuffle without repeat
|
||||
assert!(!queue.has_next());
|
||||
let result = queue.next();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
/// Test shuffle with repeat all wraps shuffle order
|
||||
///
|
||||
/// @req-test: UR-005 - Control media playback (shuffle + repeat)
|
||||
/// @req-test: DR-005 - Queue manager with shuffle and repeat
|
||||
#[test]
|
||||
fn test_next_shuffle_with_repeat_all() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
queue.toggle_shuffle(); // Enable shuffle
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
|
||||
let shuffle_order = queue.shuffle_order.clone();
|
||||
|
||||
// Move through entire shuffle order
|
||||
for _ in 1..shuffle_order.len() {
|
||||
queue.next();
|
||||
}
|
||||
|
||||
// At end, should wrap to beginning of shuffle order
|
||||
assert!(queue.has_next());
|
||||
let result = queue.next();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(queue.current_index(), Some(shuffle_order[0]));
|
||||
}
|
||||
|
||||
/// Test has_next logic accuracy across different scenarios
|
||||
///
|
||||
/// @req-test: DR-005 - Queue manager (has_next accuracy)
|
||||
/// @req-test: UR-015 - View and manage audio queue
|
||||
#[test]
|
||||
fn test_has_next_accuracy() {
|
||||
// Test 1: Empty queue
|
||||
let queue = QueueManager::new();
|
||||
assert!(!queue.has_next());
|
||||
|
||||
// Test 2: Last track with repeat off
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(2), 1); // Start at last item
|
||||
queue.set_repeat(RepeatMode::Off);
|
||||
assert!(!queue.has_next());
|
||||
|
||||
// Test 3: Last track with repeat all
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(2), 1); // Start at last item
|
||||
queue.set_repeat(RepeatMode::All);
|
||||
assert!(queue.has_next()); // Should wrap
|
||||
|
||||
// Test 4: Repeat one mode (always has next)
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(1), 0);
|
||||
queue.set_repeat(RepeatMode::One);
|
||||
assert!(queue.has_next()); // Repeats forever
|
||||
|
||||
// Test 5: Middle of queue with repeat off
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 1); // Middle item
|
||||
queue.set_repeat(RepeatMode::Off);
|
||||
assert!(queue.has_next()); // Has item_2 next
|
||||
|
||||
// Test 6: Shuffle at end without repeat
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(3), 0);
|
||||
queue.toggle_shuffle(); // Enable shuffle
|
||||
queue.set_repeat(RepeatMode::Off);
|
||||
// Move to last item in shuffle order
|
||||
let shuffle_len = queue.shuffle_order.len();
|
||||
for _ in 1..shuffle_len {
|
||||
queue.next();
|
||||
}
|
||||
assert!(!queue.has_next());
|
||||
}
|
||||
|
||||
/// Test has_next on empty queue edge case
|
||||
///
|
||||
/// @req-test: DR-005 - Queue manager (edge case: empty queue)
|
||||
#[test]
|
||||
fn test_has_next_empty_queue() {
|
||||
let queue = QueueManager::new();
|
||||
assert!(!queue.has_next());
|
||||
assert_eq!(queue.current_index(), None);
|
||||
}
|
||||
|
||||
/// Test that selecting a specific track in an album starts at the correct index
|
||||
///
|
||||
/// Reproduces the bug where clicking songs 1-5 always played song 13
|
||||
#[test]
|
||||
fn test_play_specific_track_from_album() {
|
||||
let mut queue = QueueManager::new();
|
||||
let items = create_test_items(14); // 14-track album
|
||||
|
||||
// Simulate playing track 0 (first track)
|
||||
queue.set_queue(items.clone(), 0);
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
|
||||
// Simulate playing track 3 (fourth track)
|
||||
queue.set_queue(items.clone(), 3);
|
||||
assert_eq!(queue.current_index(), Some(3));
|
||||
assert_eq!(queue.current().unwrap().id, "item_3");
|
||||
|
||||
// Simulate playing track 13 (last track)
|
||||
queue.set_queue(items, 13);
|
||||
assert_eq!(queue.current_index(), Some(13));
|
||||
assert_eq!(queue.current().unwrap().id, "item_13");
|
||||
}
|
||||
|
||||
/// Test that next() then previous() returns to the original track
|
||||
#[test]
|
||||
fn test_next_previous_roundtrip() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 2); // Start at middle track
|
||||
|
||||
assert_eq!(queue.current_index(), Some(2));
|
||||
|
||||
// Go to next track
|
||||
queue.next();
|
||||
assert_eq!(queue.current_index(), Some(3));
|
||||
|
||||
// Go back - should return to track 2
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(2));
|
||||
assert_eq!(queue.current().unwrap().id, "item_2");
|
||||
}
|
||||
|
||||
/// Test that previous() at the first track stays at first track
|
||||
#[test]
|
||||
fn test_previous_at_first_track_stays() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 0); // Start at first track
|
||||
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
|
||||
// Try to go to previous - should stay at 0
|
||||
let result = queue.previous();
|
||||
assert!(result.is_none());
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
assert_eq!(queue.current().unwrap().id, "item_0");
|
||||
}
|
||||
|
||||
/// Test that next() at last track (no repeat) stays at last track
|
||||
#[test]
|
||||
fn test_next_at_last_track_stays() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 4); // Start at last track
|
||||
|
||||
assert_eq!(queue.current_index(), Some(4));
|
||||
|
||||
// Try to go to next - should return None and stay at 4
|
||||
let result = queue.next();
|
||||
assert!(result.is_none());
|
||||
assert_eq!(queue.current_index(), Some(4));
|
||||
assert_eq!(queue.current().unwrap().id, "item_4");
|
||||
}
|
||||
|
||||
/// Test history validation prevents invalid wraparound
|
||||
#[test]
|
||||
fn test_history_validation_prevents_wraparound() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 0);
|
||||
|
||||
// Manually corrupt history to simulate the bug
|
||||
// (In the real bug, history would have last track's index)
|
||||
queue.history.push(4);
|
||||
|
||||
// Try to go previous - should detect invalid history and clear it
|
||||
let result = queue.previous();
|
||||
assert!(result.is_none()); // Should not wrap to track 4
|
||||
assert_eq!(queue.current_index(), Some(0)); // Should stay at track 0
|
||||
assert!(queue.history.is_empty()); // History should be cleared
|
||||
}
|
||||
|
||||
/// Test multiple next() calls build correct history
|
||||
#[test]
|
||||
fn test_multiple_next_builds_history() {
|
||||
let mut queue = QueueManager::new();
|
||||
queue.set_queue(create_test_items(5), 0);
|
||||
|
||||
// Navigate: 0 -> 1 -> 2 -> 3
|
||||
queue.next(); // Now at 1, history=[0]
|
||||
queue.next(); // Now at 2, history=[0, 1]
|
||||
queue.next(); // Now at 3, history=[0, 1, 2]
|
||||
|
||||
assert_eq!(queue.current_index(), Some(3));
|
||||
|
||||
// Go back through history: 3 -> 2 -> 1 -> 0
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(2));
|
||||
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
|
||||
queue.previous();
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Media Session Management
|
||||
*
|
||||
* Tracks high-level playback context (Audio/Movie/TvShow/Idle) that persists
|
||||
* beyond individual playback states. Enables persistent UI (miniplayer) and
|
||||
* proper transitions between content types.
|
||||
*
|
||||
* See SoftwareArchitecture.md Section 2.1 for state machine diagram.
|
||||
*/
|
||||
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::media::MediaItem;
|
||||
|
||||
/// Media session type tracking the high-level playback context
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum MediaSessionType {
|
||||
/// No active session - browsing library
|
||||
Idle,
|
||||
|
||||
/// Audio playback session (music, audiobooks, podcasts)
|
||||
/// Persists until explicitly dismissed
|
||||
Audio {
|
||||
/// Last/current track being played
|
||||
last_item: Option<MediaItem>,
|
||||
/// True = playing/paused, False = stopped/ended
|
||||
is_active: bool,
|
||||
},
|
||||
|
||||
/// Movie playback (single video, auto-dismiss on end)
|
||||
Movie {
|
||||
/// Currently loaded movie
|
||||
item: MediaItem,
|
||||
/// True = playing/paused, False = ended
|
||||
is_active: bool,
|
||||
},
|
||||
|
||||
/// TV show playback (supports next episode auto-advance)
|
||||
TvShow {
|
||||
/// Currently loaded episode
|
||||
item: MediaItem,
|
||||
/// Series ID for fetching next episodes
|
||||
series_id: String,
|
||||
/// True = playing/paused, False = ended
|
||||
is_active: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl MediaSessionType {
|
||||
/// Check if this is an active session (any type except Idle)
|
||||
#[allow(dead_code)]
|
||||
pub fn is_active_session(&self) -> bool {
|
||||
!matches!(self, MediaSessionType::Idle)
|
||||
}
|
||||
|
||||
/// Check if playback is currently active within the session
|
||||
#[allow(dead_code)]
|
||||
pub fn is_playing_or_paused(&self) -> bool {
|
||||
match self {
|
||||
MediaSessionType::Audio { is_active, .. } => *is_active,
|
||||
MediaSessionType::Movie { is_active, .. } => *is_active,
|
||||
MediaSessionType::TvShow { is_active, .. } => *is_active,
|
||||
MediaSessionType::Idle => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current media item if any
|
||||
#[allow(dead_code)]
|
||||
pub fn current_item(&self) -> Option<&MediaItem> {
|
||||
match self {
|
||||
MediaSessionType::Audio { last_item, .. } => last_item.as_ref(),
|
||||
MediaSessionType::Movie { item, .. } => Some(item),
|
||||
MediaSessionType::TvShow { item, .. } => Some(item),
|
||||
MediaSessionType::Idle => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages media session state transitions
|
||||
pub struct MediaSessionManager {
|
||||
current: MediaSessionType,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MediaSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current: MediaSessionType::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current session state
|
||||
pub fn current(&self) -> &MediaSessionType {
|
||||
&self.current
|
||||
}
|
||||
|
||||
/// Start an audio session with a queue
|
||||
/// Transitions: Idle → Audio(active), Any → Audio(active)
|
||||
pub fn start_audio_session(&mut self, first_item: MediaItem) {
|
||||
info!("[MediaSession] Starting audio session: {}", first_item.title);
|
||||
self.current = MediaSessionType::Audio {
|
||||
last_item: Some(first_item),
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Update audio session with new track (during playback)
|
||||
pub fn update_audio_track(&mut self, item: MediaItem) {
|
||||
if let MediaSessionType::Audio { last_item, is_active } = &mut self.current {
|
||||
info!("[MediaSession] Updating audio track: {}", item.title);
|
||||
*last_item = Some(item);
|
||||
*is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark audio session as inactive (playback ended, queue finished)
|
||||
/// Session persists for resume
|
||||
pub fn audio_session_inactive(&mut self) {
|
||||
if let MediaSessionType::Audio { is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Audio session now inactive (queue ended)");
|
||||
*is_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume audio session
|
||||
pub fn resume_audio_session(&mut self) {
|
||||
if let MediaSessionType::Audio { is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Resuming audio session");
|
||||
*is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a movie session
|
||||
/// Transitions: Any → Movie(active)
|
||||
pub fn start_movie_session(&mut self, item: MediaItem) {
|
||||
info!("[MediaSession] Starting movie session: {}", item.title);
|
||||
self.current = MediaSessionType::Movie {
|
||||
item,
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Mark movie session as inactive (playback ended)
|
||||
/// Movie sessions auto-dismiss to Idle
|
||||
pub fn movie_session_ended(&mut self) {
|
||||
if matches!(self.current, MediaSessionType::Movie { .. }) {
|
||||
info!("[MediaSession] Movie ended, transitioning to Idle");
|
||||
self.current = MediaSessionType::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a TV show session
|
||||
/// Transitions: Any → TvShow(active)
|
||||
pub fn start_tv_session(&mut self, item: MediaItem, series_id: String) {
|
||||
info!("[MediaSession] Starting TV show session: {}", item.title);
|
||||
self.current = MediaSessionType::TvShow {
|
||||
item,
|
||||
series_id,
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Mark TV show session as inactive (episode ended, awaiting next)
|
||||
pub fn tv_session_episode_ended(&mut self) {
|
||||
if let MediaSessionType::TvShow { is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Episode ended, awaiting next episode");
|
||||
*is_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to next episode in TV session
|
||||
pub fn tv_session_next_episode(&mut self, next_item: MediaItem) {
|
||||
if let MediaSessionType::TvShow { item, is_active, .. } = &mut self.current {
|
||||
info!("[MediaSession] Advancing to next episode: {}", next_item.title);
|
||||
*item = next_item;
|
||||
*is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// End TV show session (series complete or user dismissed)
|
||||
pub fn tv_session_ended(&mut self) {
|
||||
if matches!(self.current, MediaSessionType::TvShow { .. }) {
|
||||
info!("[MediaSession] TV show session ended, transitioning to Idle");
|
||||
self.current = MediaSessionType::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss/clear current session (user action)
|
||||
/// Transitions: Any → Idle
|
||||
pub fn dismiss(&mut self) {
|
||||
info!("[MediaSession] Dismissing session: {:?}", self.current);
|
||||
self.current = MediaSessionType::Idle;
|
||||
}
|
||||
|
||||
/// Check if we should show miniplayer
|
||||
pub fn should_show_miniplayer(&self) -> bool {
|
||||
matches!(self.current, MediaSessionType::Audio { .. })
|
||||
}
|
||||
|
||||
/// Check if we should show video player
|
||||
pub fn should_show_video_player(&self) -> bool {
|
||||
matches!(
|
||||
self.current,
|
||||
MediaSessionType::Movie { .. } | MediaSessionType::TvShow { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MediaSessionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::media::MediaSource;
|
||||
|
||||
fn create_test_audio_item(title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
id: title.to_string(),
|
||||
title: title.to_string(),
|
||||
name: Some(title.to_string()),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
album: Some("Test Album".to_string()),
|
||||
album_name: Some("Test Album".to_string()),
|
||||
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: crate::player::media::MediaType::Audio,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/audio.mp3".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_movie_item(title: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
id: title.to_string(),
|
||||
title: title.to_string(),
|
||||
name: Some(title.to_string()),
|
||||
artist: None,
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
item_type: Some("Movie".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(7200.0),
|
||||
artwork_url: None,
|
||||
media_type: crate::player::media::MediaType::Video,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/movie.mp4".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initial_state_is_idle() {
|
||||
let manager = MediaSessionManager::new();
|
||||
assert_eq!(manager.current(), &MediaSessionType::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_session_lifecycle() {
|
||||
let mut manager = MediaSessionManager::new();
|
||||
|
||||
// Start audio session
|
||||
let track = create_test_audio_item("Track 1");
|
||||
manager.start_audio_session(track.clone());
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: true, .. }
|
||||
));
|
||||
assert!(manager.should_show_miniplayer());
|
||||
|
||||
// Update to next track
|
||||
let track2 = create_test_audio_item("Track 2");
|
||||
manager.update_audio_track(track2);
|
||||
assert!(manager.current().is_playing_or_paused());
|
||||
|
||||
// Queue ends, session goes inactive
|
||||
manager.audio_session_inactive();
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: false, .. }
|
||||
));
|
||||
assert!(manager.should_show_miniplayer()); // Still shows!
|
||||
|
||||
// Resume
|
||||
manager.resume_audio_session();
|
||||
assert!(manager.current().is_playing_or_paused());
|
||||
|
||||
// Dismiss
|
||||
manager.dismiss();
|
||||
assert_eq!(manager.current(), &MediaSessionType::Idle);
|
||||
assert!(!manager.should_show_miniplayer());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_movie_session_auto_dismiss() {
|
||||
let mut manager = MediaSessionManager::new();
|
||||
|
||||
let movie = create_test_movie_item("Test Movie");
|
||||
manager.start_movie_session(movie);
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Movie { is_active: true, .. }
|
||||
));
|
||||
assert!(manager.should_show_video_player());
|
||||
|
||||
// Movie ends, auto-dismiss to Idle
|
||||
manager.movie_session_ended();
|
||||
assert_eq!(manager.current(), &MediaSessionType::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_replacement() {
|
||||
let mut manager = MediaSessionManager::new();
|
||||
|
||||
// Start with audio
|
||||
let track = create_test_audio_item("Track 1");
|
||||
manager.start_audio_session(track);
|
||||
assert!(manager.should_show_miniplayer());
|
||||
|
||||
// Switch to movie (replaces audio session)
|
||||
let movie = create_test_movie_item("Test Movie");
|
||||
manager.start_movie_session(movie);
|
||||
assert!(!manager.should_show_miniplayer());
|
||||
assert!(manager.should_show_video_player());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Sleep timer mode - determines when playback should stop
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum SleepTimerMode {
|
||||
/// Timer is off
|
||||
Off,
|
||||
/// Stop after a specific time duration
|
||||
Time {
|
||||
#[serde(rename = "endTime")]
|
||||
end_time: i64, // Unix timestamp in milliseconds
|
||||
},
|
||||
/// Stop at the end of current track
|
||||
EndOfTrack,
|
||||
/// Stop after N more episodes complete (TV episodes only, not audio tracks)
|
||||
Episodes { remaining: u32 },
|
||||
}
|
||||
|
||||
/// Sleep timer state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SleepTimerState {
|
||||
pub mode: SleepTimerMode,
|
||||
pub remaining_seconds: u32,
|
||||
}
|
||||
|
||||
impl Default for SleepTimerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: SleepTimerMode::Off,
|
||||
remaining_seconds: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SleepTimerState {
|
||||
/// Check if timer is active
|
||||
pub fn is_active(&self) -> bool {
|
||||
!matches!(self.mode, SleepTimerMode::Off)
|
||||
}
|
||||
|
||||
/// Update remaining seconds for time-based timer
|
||||
pub fn update_remaining_seconds(&mut self) {
|
||||
if let SleepTimerMode::Time { end_time } = self.mode {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
self.remaining_seconds = if now >= end_time {
|
||||
0
|
||||
} else {
|
||||
((end_time - now) / 1000).max(0) as u32
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrement episode counter, returns true if should stop
|
||||
/// Only counts TV episodes, not audio tracks
|
||||
pub fn decrement_episode(&mut self) -> bool {
|
||||
match &mut self.mode {
|
||||
SleepTimerMode::Episodes { remaining } => {
|
||||
if *remaining <= 1 {
|
||||
self.mode = SleepTimerMode::Off;
|
||||
self.remaining_seconds = 0;
|
||||
true
|
||||
} else {
|
||||
*remaining -= 1;
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the timer
|
||||
pub fn cancel(&mut self) {
|
||||
self.mode = SleepTimerMode::Off;
|
||||
self.remaining_seconds = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sleep_timer_episode_decrement() {
|
||||
let mut timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Episodes { remaining: 2 },
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
|
||||
assert!(!timer.decrement_episode());
|
||||
assert!(matches!(
|
||||
timer.mode,
|
||||
SleepTimerMode::Episodes { remaining: 1 }
|
||||
));
|
||||
|
||||
assert!(timer.decrement_episode());
|
||||
assert!(matches!(timer.mode, SleepTimerMode::Off));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sleep_timer_is_active() {
|
||||
let timer = SleepTimerState::default();
|
||||
assert!(!timer.is_active());
|
||||
|
||||
let timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Time { end_time: 0 },
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
assert!(timer.is_active());
|
||||
|
||||
let timer = SleepTimerState {
|
||||
mode: SleepTimerMode::EndOfTrack,
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
assert!(timer.is_active());
|
||||
|
||||
let timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Episodes { remaining: 3 },
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
assert!(timer.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sleep_timer_cancel() {
|
||||
let mut timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Episodes { remaining: 5 },
|
||||
remaining_seconds: 300,
|
||||
};
|
||||
|
||||
timer.cancel();
|
||||
assert!(matches!(timer.mode, SleepTimerMode::Off));
|
||||
assert_eq!(timer.remaining_seconds, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_remaining_seconds() {
|
||||
let end_time = chrono::Utc::now().timestamp_millis() + 30000; // 30 seconds from now
|
||||
let mut timer = SleepTimerState {
|
||||
mode: SleepTimerMode::Time { end_time },
|
||||
remaining_seconds: 0,
|
||||
};
|
||||
|
||||
timer.update_remaining_seconds();
|
||||
// Should be approximately 30 seconds (allow for small time difference)
|
||||
assert!(timer.remaining_seconds >= 29 && timer.remaining_seconds <= 31);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::media::MediaItem;
|
||||
|
||||
/// Tracks why playback ended to determine autoplay behavior
|
||||
///
|
||||
/// @req: UR-005 - Control media playback (autoplay logic)
|
||||
/// @req: DR-001 - Player state machine (end reason tracking)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EndReason {
|
||||
/// Track played to completion (natural end) - trigger autoplay
|
||||
Finished,
|
||||
/// User pressed next/previous - already handled, don't autoplay
|
||||
UserSkip,
|
||||
/// User stopped playback - don't autoplay
|
||||
UserStop,
|
||||
/// Playback error - don't autoplay
|
||||
Error,
|
||||
/// User selected a different track - don't autoplay
|
||||
NewTrackLoaded,
|
||||
}
|
||||
|
||||
/// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
|
||||
///
|
||||
/// @req: DR-001 - Player state machine (idle, loading, playing, paused, seeking, error)
|
||||
/// @req: UR-005 - Control media playback (state tracking)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum PlayerState {
|
||||
#[default]
|
||||
/// No media loaded
|
||||
Idle,
|
||||
/// Media is being loaded/buffered
|
||||
Loading { media: MediaItem },
|
||||
/// Media is playing
|
||||
Playing {
|
||||
media: MediaItem,
|
||||
/// Current position in seconds
|
||||
position: f64,
|
||||
/// Total duration in seconds
|
||||
duration: f64,
|
||||
},
|
||||
/// Media is paused
|
||||
Paused {
|
||||
media: MediaItem,
|
||||
/// Current position in seconds
|
||||
position: f64,
|
||||
/// Total duration in seconds
|
||||
duration: f64,
|
||||
},
|
||||
/// Seeking to a new position
|
||||
Seeking {
|
||||
media: MediaItem,
|
||||
/// Target position in seconds
|
||||
target: f64,
|
||||
},
|
||||
/// An error occurred
|
||||
Error {
|
||||
media: Option<MediaItem>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlayerState {
|
||||
/// Get the current playback position if available
|
||||
pub fn position(&self) -> Option<f64> {
|
||||
match self {
|
||||
PlayerState::Playing { position, .. } => Some(*position),
|
||||
PlayerState::Paused { position, .. } => Some(*position),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the player is currently playing
|
||||
pub fn is_playing(&self) -> bool {
|
||||
matches!(self, PlayerState::Playing { .. })
|
||||
}
|
||||
|
||||
/// Check if the player is currently paused
|
||||
#[allow(dead_code)]
|
||||
pub fn is_paused(&self) -> bool {
|
||||
matches!(self, PlayerState::Paused { .. })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
// Hybrid repository - parallel racing between cache and server
|
||||
//
|
||||
// @req: UR-002 - Access media when online or offline
|
||||
// @req: IR-013 - SQLite integration for local database
|
||||
// @req: DR-012 - Local database for media metadata cache
|
||||
// @req: DR-013 - Repository pattern for online/offline data access
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, warn};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use super::{MediaRepository, OnlineRepository, OfflineRepository, types::*};
|
||||
|
||||
/// Hybrid repository combining online and offline data sources
|
||||
///
|
||||
/// Uses cache-first parallel racing strategy:
|
||||
/// - Runs SQLite cache and HTTP server queries in parallel
|
||||
/// - Cache has 100ms timeout for fast feedback
|
||||
/// - Returns cache result if it has meaningful content
|
||||
/// - Falls back to server result if cache is empty/stale
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: DR-012 - Local database for media metadata cache
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
pub struct HybridRepository {
|
||||
online: Arc<OnlineRepository>,
|
||||
offline: Arc<OfflineRepository>,
|
||||
}
|
||||
|
||||
impl HybridRepository {
|
||||
pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Self {
|
||||
Self {
|
||||
online: Arc::new(online),
|
||||
offline: Arc::new(offline),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get video stream URL with optional seeking support.
|
||||
/// This method is online-only since offline playback uses local file paths.
|
||||
pub async fn get_video_stream_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
|
||||
}
|
||||
|
||||
/// Race cache vs server, return first valid result
|
||||
/// Prefer cache if it has meaningful content, otherwise use server
|
||||
///
|
||||
/// Core algorithm of the cache-first parallel racing strategy.
|
||||
/// Runs both cache and server queries concurrently, then:
|
||||
/// 1. If cache has meaningful content → return cache (fast path)
|
||||
/// 2. If cache is empty/stale → return server (fresh data)
|
||||
/// 3. If server fails → return cache even if empty (offline fallback)
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
async fn parallel_race<T, F1, F2>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
server_future: F2,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
{
|
||||
// Wait for both to complete (cache has 100ms timeout)
|
||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
||||
|
||||
// Prefer cache if it has meaningful content
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
debug!("[HybridRepo] Using cache result (has content)");
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to server result
|
||||
match server_result {
|
||||
Ok(data) => {
|
||||
debug!("[HybridRepo] Using server result");
|
||||
// TODO: Spawn background cache update
|
||||
Ok(data)
|
||||
}
|
||||
Err(e) => {
|
||||
// Server failed, try to return cache even if empty
|
||||
cache_result.or(Err(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple timeout wrapper for cache queries (100ms timeout)
|
||||
///
|
||||
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
||||
async fn cache_with_timeout<T>(
|
||||
&self,
|
||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
) -> Result<T, RepoError> {
|
||||
timeout(Duration::from_millis(100), future)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(RepoError::Database {
|
||||
message: "Cache query timeout".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaRepository for HybridRepository {
|
||||
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
// Libraries change infrequently, try cache first with fast timeout
|
||||
let cache_future = self.cache_with_timeout(self.offline.get_libraries());
|
||||
let server_future = self.online.get_libraries();
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let offline_for_save = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id = parent_id.to_string();
|
||||
let parent_id_clone = parent_id.clone();
|
||||
let parent_id_for_save = parent_id.clone();
|
||||
let opts_clone = options.clone();
|
||||
|
||||
// Check cache first to see if we have data
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_items(&parent_id, opts_clone).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_items(&parent_id_clone, options).await
|
||||
};
|
||||
|
||||
// Wait for both, prefer cache if available
|
||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
||||
|
||||
// Check if cache had meaningful content
|
||||
let cache_had_content = cache_result.as_ref()
|
||||
.map(|data| data.has_content())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Prefer cache if it has content
|
||||
let result = if cache_had_content {
|
||||
debug!("[HybridRepo] Using cached data for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||
cache_result?
|
||||
} else {
|
||||
// Use server result and save to cache for next time
|
||||
let server_data = server_result?;
|
||||
|
||||
if !server_data.items.is_empty() {
|
||||
let items_clone = server_data.items.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await {
|
||||
warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e);
|
||||
} else {
|
||||
debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
server_data
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let item_id = item_id.to_string();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_item(&item_id).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_item(&item_id_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_latest_items(&self, parent_id: &str, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id = parent_id.to_string();
|
||||
let parent_id_clone = parent_id.clone();
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_latest_items(&parent_id, limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_latest_items(&parent_id_clone, limit_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id_str = parent_id.map(|s| s.to_string());
|
||||
let parent_id_clone = parent_id_str.clone();
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_resume_items(parent_id_str.as_deref(), limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_resume_items(parent_id_clone.as_deref(), limit_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_next_up_episodes(&self, series_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next up is dynamic, always fetch from server
|
||||
self.online.get_next_up_episodes(series_id, limit).await
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_recently_played_audio(limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_recently_played_audio(limit_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_resume_movies(limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_resume_movies(limit_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id_str = parent_id.map(|s| s.to_string());
|
||||
let parent_id_clone = parent_id_str.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_genres(parent_id_str.as_deref()).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_genres(parent_id_clone.as_deref()).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let query = query.to_string();
|
||||
let query_clone = query.clone();
|
||||
let opts_clone = options.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.search(&query, opts_clone).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.search(&query_clone, options).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
// Playback info requires server communication for transcoding decisions
|
||||
self.online.get_playback_info(item_id).await
|
||||
}
|
||||
|
||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
|
||||
// Stream URLs require server communication - delegate to online repository
|
||||
self.online.get_audio_stream_url(item_id).await
|
||||
}
|
||||
|
||||
async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
|
||||
// Playback reporting goes directly to server
|
||||
self.online.report_playback_start(item_id, position_ticks).await
|
||||
}
|
||||
|
||||
async fn report_playback_progress(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
|
||||
// Playback reporting goes directly to server
|
||||
self.online.report_playback_progress(item_id, position_ticks).await
|
||||
}
|
||||
|
||||
async fn report_playback_stopped(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
|
||||
// Playback reporting goes directly to server
|
||||
self.online.report_playback_stopped(item_id, position_ticks).await
|
||||
}
|
||||
|
||||
fn get_image_url(&self, item_id: &str, image_type: ImageType, options: Option<ImageOptions>) -> String {
|
||||
// Always use online URL for images (thumbnail cache handles offline)
|
||||
self.online.get_image_url(item_id, image_type, options)
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.mark_favorite(item_id).await
|
||||
}
|
||||
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.unmark_favorite(item_id).await
|
||||
}
|
||||
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let person_id = person_id.to_string();
|
||||
let person_id_clone = person_id.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_person(&person_id).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_person(&person_id_clone).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_items_by_person(&self, person_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let person_id = person_id.to_string();
|
||||
let person_id_clone = person_id.clone();
|
||||
let opts_clone = options.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_items_by_person(&person_id, opts_clone).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_items_by_person(&person_id_clone, options).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_similar_items(&self, item_id: &str, limit: Option<usize>) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let item_id = item_id.to_string();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_similar_items(&item_id, limit).await
|
||||
});
|
||||
|
||||
let server_future = async move {
|
||||
online.get_similar_items(&item_id_clone, limit).await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Mock offline repository that tracks queries and saves
|
||||
struct MockOfflineRepo {
|
||||
items: Arc<Mutex<Vec<MediaItem>>>,
|
||||
query_count: Arc<Mutex<usize>>,
|
||||
save_count: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl MockOfflineRepo {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
items: Arc::new(Mutex::new(Vec::new())),
|
||||
query_count: Arc::new(Mutex::new(0)),
|
||||
save_count: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_query_count(&self) -> usize {
|
||||
*self.query_count.lock().unwrap()
|
||||
}
|
||||
|
||||
fn get_save_count(&self) -> usize {
|
||||
*self.save_count.lock().unwrap()
|
||||
}
|
||||
|
||||
async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> {
|
||||
*self.save_count.lock().unwrap() += 1;
|
||||
*self.items.lock().unwrap() = items.to_vec();
|
||||
Ok(items.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaRepository for MockOfflineRepo {
|
||||
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
*self.query_count.lock().unwrap() += 1;
|
||||
let items = self.items.lock().unwrap().clone();
|
||||
let count = items.len();
|
||||
Ok(SearchResult {
|
||||
items,
|
||||
total_record_count: count,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_latest_items(&self, _parent_id: &str, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_resume_items(&self, _parent_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_next_up_episodes(&self, _series_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(&self, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_resume_movies(&self, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str, _options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_progress(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_stopped(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_image_url(&self, _item_id: &str, _image_type: ImageType, _options: Option<ImageOptions>) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock online repository that returns predefined items
|
||||
struct MockOnlineRepo {
|
||||
items: Vec<MediaItem>,
|
||||
query_count: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl MockOnlineRepo {
|
||||
fn new(items: Vec<MediaItem>) -> Self {
|
||||
Self {
|
||||
items,
|
||||
query_count: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_query_count(&self) -> usize {
|
||||
*self.query_count.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MediaRepository for MockOnlineRepo {
|
||||
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
*self.query_count.lock().unwrap() += 1;
|
||||
Ok(SearchResult {
|
||||
items: self.items.clone(),
|
||||
total_record_count: self.items.len(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_latest_items(&self, _parent_id: &str, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_resume_items(&self, _parent_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_next_up_episodes(&self, _series_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(&self, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_resume_movies(&self, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str, _options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_progress(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn report_playback_stopped(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_image_url(&self, _item_id: &str, _image_type: ImageType, _options: Option<ImageOptions>) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_item(id: &str, name: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
item_type: "Movie".to_string(),
|
||||
server_id: "test-server".to_string(),
|
||||
parent_id: Some("parent-123".to_string()),
|
||||
library_id: Some("library-456".to_string()),
|
||||
overview: Some("Test overview".to_string()),
|
||||
genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
|
||||
runtime_ticks: Some(7200000000),
|
||||
production_year: Some(2024),
|
||||
community_rating: Some(8.5),
|
||||
official_rating: Some("PG-13".to_string()),
|
||||
primary_image_tag: Some("image-tag-123".to_string()),
|
||||
backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
album_name: None,
|
||||
album_artist: None,
|
||||
artists: None,
|
||||
artist_items: None,
|
||||
index_number: None,
|
||||
series_id: None,
|
||||
series_name: None,
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
parent_index_number: None,
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
people: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to test the caching logic
|
||||
struct TestHybridRepo {
|
||||
offline: Arc<MockOfflineRepo>,
|
||||
online: Arc<MockOnlineRepo>,
|
||||
}
|
||||
|
||||
impl TestHybridRepo {
|
||||
fn new(server_items: Vec<MediaItem>) -> Self {
|
||||
let offline = Arc::new(MockOfflineRepo::new());
|
||||
let online = Arc::new(MockOnlineRepo::new(server_items));
|
||||
Self { offline, online }
|
||||
}
|
||||
|
||||
/// Test version of get_items that implements the cache logic
|
||||
async fn get_items(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let offline_for_save = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let parent_id = parent_id.to_string();
|
||||
let parent_id_clone = parent_id.clone();
|
||||
let parent_id_for_save = parent_id.clone();
|
||||
|
||||
// Check cache first
|
||||
let cache_future = async move {
|
||||
offline.get_items(&parent_id, None).await
|
||||
};
|
||||
|
||||
let server_future = async move {
|
||||
online.get_items(&parent_id_clone, None).await
|
||||
};
|
||||
|
||||
// Wait for both, prefer cache if available
|
||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
||||
|
||||
// Check if cache had meaningful content
|
||||
let cache_had_content = cache_result.as_ref()
|
||||
.map(|data| data.has_content())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Prefer cache if it has content (mimics hybrid.rs get_items logic)
|
||||
let result = if cache_had_content {
|
||||
cache_result?
|
||||
} else {
|
||||
// Use server result and save to cache for next time
|
||||
let server_data = server_result?;
|
||||
|
||||
if !server_data.items.is_empty() {
|
||||
let items_clone = server_data.items.clone();
|
||||
offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await?;
|
||||
}
|
||||
|
||||
server_data
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Test cache miss saves server data to cache for next time
|
||||
///
|
||||
/// @req-test: UR-002 - Access media when online or offline
|
||||
/// @req-test: DR-013 - Repository pattern for online/offline data access
|
||||
/// @req-test: DR-012 - Local database for media metadata cache
|
||||
#[tokio::test]
|
||||
async fn test_cache_miss_saves_to_cache() {
|
||||
// Setup: Server has 3 items, cache is empty
|
||||
let server_items = vec![
|
||||
create_test_item("item-1", "Movie 1"),
|
||||
create_test_item("item-2", "Movie 2"),
|
||||
create_test_item("item-3", "Movie 3"),
|
||||
];
|
||||
|
||||
let repo = TestHybridRepo::new(server_items.clone());
|
||||
|
||||
// First request - cache miss
|
||||
let result = repo.get_items("parent-123").await.unwrap();
|
||||
|
||||
// Should return server items
|
||||
assert_eq!(result.items.len(), 3);
|
||||
assert_eq!(result.items[0].id, "item-1");
|
||||
|
||||
// Should have queried both cache and server
|
||||
assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried once");
|
||||
assert_eq!(repo.online.get_query_count(), 1, "Server should be queried once");
|
||||
|
||||
// Should have saved to cache
|
||||
assert_eq!(repo.offline.get_save_count(), 1, "Should save to cache on miss");
|
||||
}
|
||||
|
||||
/// Test cache hit prevents duplicate save to cache
|
||||
///
|
||||
/// Verifies parallel racing strategy: both cache and server are queried,
|
||||
/// but when cache has content, it's used and no duplicate save occurs.
|
||||
///
|
||||
/// @req-test: UR-002 - Access media when online or offline
|
||||
/// @req-test: DR-013 - Repository pattern for online/offline data access
|
||||
/// @req-test: DR-012 - Local database cache (avoid duplicate writes)
|
||||
#[tokio::test]
|
||||
async fn test_cache_hit_no_save() {
|
||||
// Setup: Server has 3 items, we'll pre-populate cache
|
||||
let server_items = vec![
|
||||
create_test_item("item-1", "Movie 1"),
|
||||
create_test_item("item-2", "Movie 2"),
|
||||
create_test_item("item-3", "Movie 3"),
|
||||
];
|
||||
|
||||
let repo = TestHybridRepo::new(server_items.clone());
|
||||
|
||||
// Pre-populate cache
|
||||
repo.offline.save_to_cache("parent-123", &server_items).await.unwrap();
|
||||
assert_eq!(repo.offline.get_save_count(), 1);
|
||||
|
||||
// Second request - cache hit
|
||||
let result = repo.get_items("parent-123").await.unwrap();
|
||||
|
||||
// Should return cached items
|
||||
assert_eq!(result.items.len(), 3);
|
||||
assert_eq!(result.items[0].id, "item-1");
|
||||
|
||||
// Should have queried cache and server (parallel race)
|
||||
assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried");
|
||||
assert_eq!(repo.online.get_query_count(), 1, "Server is queried in parallel");
|
||||
|
||||
// Should NOT have saved again (no duplicate save)
|
||||
assert_eq!(repo.offline.get_save_count(), 1, "Should NOT save when using cache");
|
||||
}
|
||||
|
||||
/// Test empty results are not saved to cache
|
||||
///
|
||||
/// @req-test: DR-013 - Repository pattern (edge case handling)
|
||||
/// @req-test: DR-012 - Local database cache (avoid saving empty data)
|
||||
#[tokio::test]
|
||||
async fn test_empty_cache_returns_empty_result() {
|
||||
// Setup: Server has no items
|
||||
let repo = TestHybridRepo::new(vec![]);
|
||||
|
||||
// Request with empty server
|
||||
let result = repo.get_items("parent-123").await.unwrap();
|
||||
|
||||
// Should return empty result
|
||||
assert_eq!(result.items.len(), 0);
|
||||
|
||||
// Should NOT save empty results
|
||||
assert_eq!(repo.offline.get_save_count(), 0, "Should not save empty results");
|
||||
}
|
||||
|
||||
/// Test SearchResult::has_content helper method
|
||||
///
|
||||
/// @req-test: DR-013 - Repository pattern (content detection helper)
|
||||
#[tokio::test]
|
||||
async fn test_has_content_check() {
|
||||
// Test that SearchResult::has_content works correctly
|
||||
let empty_result = SearchResult {
|
||||
items: vec![],
|
||||
total_record_count: 0,
|
||||
};
|
||||
assert!(!empty_result.has_content(), "Empty result should not have content");
|
||||
|
||||
let result_with_items = SearchResult {
|
||||
items: vec![create_test_item("item-1", "Movie 1")],
|
||||
total_record_count: 1,
|
||||
};
|
||||
assert!(result_with_items.has_content(), "Result with items should have content");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
pub mod types;
|
||||
pub mod online;
|
||||
pub mod offline;
|
||||
pub mod hybrid;
|
||||
|
||||
pub use types::*;
|
||||
pub use online::OnlineRepository;
|
||||
pub use offline::OfflineRepository;
|
||||
pub use hybrid::HybridRepository;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Repository trait for media access (online, offline, or hybrid)
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: UR-007 - Navigate media in library
|
||||
/// @req: UR-008 - Search media across libraries
|
||||
/// @req: IR-010 - Jellyfin API client for library browsing
|
||||
/// @req: DR-012 - Local database for media metadata cache
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
#[async_trait]
|
||||
pub trait MediaRepository: Send + Sync {
|
||||
/// Get all libraries
|
||||
///
|
||||
/// @req: UR-007 - Navigate media in library
|
||||
/// @req: JA-003 - Get user library views
|
||||
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError>;
|
||||
|
||||
/// Get items in a library or parent
|
||||
///
|
||||
/// @req: UR-007 - Navigate media in library
|
||||
/// @req: JA-004 - Get library items (paginated)
|
||||
async fn get_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
|
||||
/// Get a single item by ID
|
||||
///
|
||||
/// @req: UR-007 - Navigate media in library
|
||||
/// @req: JA-005 - Get item details and metadata
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
/// Get latest items in a library
|
||||
///
|
||||
/// @req: UR-024 - View recently added content on server
|
||||
/// @req: JA-016 - Get recently added items
|
||||
async fn get_latest_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get resume items (continue watching/listening)
|
||||
///
|
||||
/// @req: UR-019 - Resume playback from where you left off
|
||||
/// @req: UR-023 - View "Next Up" / Continue Watching on home screen
|
||||
/// @req: JA-015 - Get "Continue Watching" items
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get next up episodes
|
||||
///
|
||||
/// @req: UR-023 - View "Next Up" / Continue Watching; auto-play next episode
|
||||
/// @req: JA-014 - Get "Next Up" items
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
series_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get recently played audio
|
||||
async fn get_recently_played_audio(
|
||||
&self,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get resume movies
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
/// Get genres
|
||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError>;
|
||||
|
||||
/// Search for items
|
||||
///
|
||||
/// @req: UR-008 - Search media across libraries
|
||||
/// @req: JA-006 - Search across libraries
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
|
||||
/// Get playback info for streaming
|
||||
///
|
||||
/// @req: UR-003 - Play videos
|
||||
/// @req: UR-004 - Play audio uninterrupted
|
||||
/// @req: JA-007 - Get playback info and stream URL
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError>;
|
||||
|
||||
/// Get audio stream URL for a track
|
||||
///
|
||||
/// @req: UR-004 - Play audio uninterrupted
|
||||
/// @req: JA-007 - Get playback info and stream URL
|
||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
||||
|
||||
/// Report playback start
|
||||
///
|
||||
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
||||
/// @req: JA-010 - Report playback start
|
||||
async fn report_playback_start(
|
||||
&self,
|
||||
item_id: &str,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), RepoError>;
|
||||
|
||||
/// Report playback progress
|
||||
///
|
||||
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
||||
/// @req: JA-011 - Report playback progress (periodic)
|
||||
async fn report_playback_progress(
|
||||
&self,
|
||||
item_id: &str,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), RepoError>;
|
||||
|
||||
/// Report playback stopped
|
||||
///
|
||||
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
|
||||
/// @req: JA-012 - Report playback stopped
|
||||
async fn report_playback_stopped(
|
||||
&self,
|
||||
item_id: &str,
|
||||
position_ticks: i64,
|
||||
) -> Result<(), RepoError>;
|
||||
|
||||
/// Get image URL (synchronous - just constructs URL)
|
||||
fn get_image_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
image_type: ImageType,
|
||||
options: Option<ImageOptions>,
|
||||
) -> String;
|
||||
|
||||
/// Mark item as favorite
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Unmark item as favorite
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
/// Get person details
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
|
||||
|
||||
/// Get items by person (filmography)
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
person_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
|
||||
/// Get similar/related items for a movie or show
|
||||
///
|
||||
/// @req: UR-009 - Discover similar content based on current item
|
||||
async fn get_similar_items(
|
||||
&self,
|
||||
item_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResult, RepoError>;
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Error types for repository operations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum RepoError {
|
||||
Network { message: String },
|
||||
Authentication { message: String },
|
||||
NotFound { message: String },
|
||||
Server { message: String },
|
||||
Database { message: String },
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RepoError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RepoError::Network { message } => write!(f, "Network error: {}", message),
|
||||
RepoError::Authentication { message } => write!(f, "Authentication error: {}", message),
|
||||
RepoError::NotFound { message } => write!(f, "Not found: {}", message),
|
||||
RepoError::Server { message } => write!(f, "Server error: {}", message),
|
||||
RepoError::Database { message } => write!(f, "Database error: {}", message),
|
||||
RepoError::Offline => write!(f, "Offline - no server connection"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RepoError {}
|
||||
|
||||
/// Library (media collection)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Library {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub collection_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_tag: Option<String>,
|
||||
}
|
||||
|
||||
/// User-specific data for an item (playback state, favorites, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserData {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playback_position_ticks: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_played: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_favorite: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub play_count: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_played_date: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playback_context_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playback_context_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Artist item with ID and name (for clickable artist links)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct ArtistItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Person (cast/crew member) - for movies, series, and episodes
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Person {
|
||||
/// Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
|
||||
#[serde(alias = "Id")]
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
/// Deserializes from API's "Name" field (PascalCase), serializes as "name" (camelCase to frontend)
|
||||
#[serde(alias = "Name")]
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Person type from Jellyfin API (Actor, Director, Writer, etc.)
|
||||
/// Deserializes from API's "Type" field (PascalCase), serializes as "type" (camelCase to frontend)
|
||||
#[serde(rename = "type")]
|
||||
#[serde(alias = "Type")]
|
||||
#[serde(default)]
|
||||
pub person_type: String,
|
||||
/// Deserializes from API's "Role" field (PascalCase), serializes as "role" (camelCase to frontend)
|
||||
#[serde(alias = "Role")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
/// Deserializes from API's "PrimaryImageTag" field (PascalCase), serializes as "primaryImageTag" (camelCase to frontend)
|
||||
#[serde(alias = "PrimaryImageTag")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
}
|
||||
|
||||
/// Media item
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
pub server_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub library_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub overview: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub genres: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub production_year: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub community_rating: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub official_rating: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_ticks: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backdrop_image_tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_backdrop_image_tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub album_artist: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub artists: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub artist_items: Option<Vec<ArtistItem>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub index_number: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_index_number: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub series_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub series_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub season_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub season_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_data: Option<UserData>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media_streams: Option<Vec<MediaStream>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub people: Option<Vec<Person>>,
|
||||
}
|
||||
|
||||
/// Media stream information (audio, video, subtitle tracks)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaStream {
|
||||
#[serde(rename = "type")]
|
||||
pub stream_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub codec: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_title: Option<String>,
|
||||
pub index: i32,
|
||||
pub is_default: bool,
|
||||
pub is_forced: bool,
|
||||
}
|
||||
|
||||
/// Media source information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaSource {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub container: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate: Option<i32>,
|
||||
pub supports_direct_play: bool,
|
||||
pub supports_direct_stream: bool,
|
||||
pub supports_transcoding: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direct_stream_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Search result with pagination
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResult {
|
||||
pub items: Vec<MediaItem>,
|
||||
pub total_record_count: usize,
|
||||
}
|
||||
|
||||
/// Options for querying items
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetItemsOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start_index: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sort_order: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_item_types: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recursive: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fields: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub genres: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Options for search queries
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_item_types: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub search_term: Option<String>,
|
||||
}
|
||||
|
||||
/// Playback information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaybackInfo {
|
||||
pub media_source_id: String,
|
||||
pub play_session_id: String,
|
||||
pub stream_url: String,
|
||||
pub direct_play: bool,
|
||||
pub needs_transcoding: bool,
|
||||
}
|
||||
|
||||
/// Genre
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Genre {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Image type
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ImageType {
|
||||
Primary,
|
||||
Backdrop,
|
||||
Banner,
|
||||
Thumb,
|
||||
Logo,
|
||||
}
|
||||
|
||||
impl ImageType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ImageType::Primary => "Primary",
|
||||
ImageType::Backdrop => "Backdrop",
|
||||
ImageType::Banner => "Banner",
|
||||
ImageType::Thumb => "Thumb",
|
||||
ImageType::Logo => "Logo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Image options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImageOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_height: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tag: Option<String>,
|
||||
}
|
||||
|
||||
/// Trait for checking if data has meaningful content
|
||||
pub trait MeaningfulContent {
|
||||
fn has_content(&self) -> bool;
|
||||
}
|
||||
|
||||
impl MeaningfulContent for Vec<Library> {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for Vec<MediaItem> {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for SearchResult {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for MediaItem {
|
||||
fn has_content(&self) -> bool {
|
||||
true // A single item always has content if it exists
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for Vec<Genre> {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeaningfulContent for PlaybackInfo {
|
||||
fn has_content(&self) -> bool {
|
||||
!self.stream_url.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_artist_item_deserialize_pascal_case() {
|
||||
// Test that ArtistItem correctly deserializes PascalCase JSON from Jellyfin API
|
||||
let json = r#"{"Id": "artist123", "Name": "Bob Dylan"}"#;
|
||||
let result: Result<ArtistItem, _> = serde_json::from_str(json);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let artist = result.unwrap();
|
||||
assert_eq!(artist.id, "artist123");
|
||||
assert_eq!(artist.name, "Bob Dylan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artist_item_deserialize_array() {
|
||||
// Test deserializing array of ArtistItems (common in API responses)
|
||||
let json = r#"[
|
||||
{"Id": "artist1", "Name": "Bob Dylan"},
|
||||
{"Id": "artist2", "Name": "Johnny Cash"}
|
||||
]"#;
|
||||
let result: Result<Vec<ArtistItem>, _> = serde_json::from_str(json);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let artists = result.unwrap();
|
||||
assert_eq!(artists.len(), 2);
|
||||
assert_eq!(artists[0].id, "artist1");
|
||||
assert_eq!(artists[0].name, "Bob Dylan");
|
||||
assert_eq!(artists[1].id, "artist2");
|
||||
assert_eq!(artists[1].name, "Johnny Cash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artist_item_serialize() {
|
||||
// Test that ArtistItem serializes to PascalCase for consistency
|
||||
let artist = ArtistItem {
|
||||
id: "test-id".to_string(),
|
||||
name: "Test Artist".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&artist).expect("Failed to serialize");
|
||||
assert!(json.contains(r#""Id":"test-id""#));
|
||||
assert!(json.contains(r#""Name":"Test Artist""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_item_with_primary_image_tag() {
|
||||
// Test that MediaItem correctly handles primary_image_tag
|
||||
let json = r#"{
|
||||
"id": "item123",
|
||||
"name": "Test Item",
|
||||
"type": "MusicAlbum",
|
||||
"serverId": "server1",
|
||||
"primaryImageTag": "tag123"
|
||||
}"#;
|
||||
|
||||
let result: Result<MediaItem, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let item = result.unwrap();
|
||||
assert_eq!(item.id, "item123");
|
||||
assert_eq!(item.name, "Test Item");
|
||||
assert_eq!(item.primary_image_tag, Some("tag123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_item_with_artists() {
|
||||
// Test MediaItem with artists array
|
||||
let json = r#"{
|
||||
"id": "track1",
|
||||
"name": "Test Track",
|
||||
"type": "Audio",
|
||||
"serverId": "server1",
|
||||
"artists": ["Artist 1", "Artist 2"]
|
||||
}"#;
|
||||
|
||||
let result: Result<MediaItem, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let item = result.unwrap();
|
||||
let artists = item.artists.expect("Expected artists");
|
||||
assert_eq!(artists.len(), 2);
|
||||
assert_eq!(artists[0], "Artist 1");
|
||||
assert_eq!(artists[1], "Artist 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_result_meaningful_content() {
|
||||
// Test MeaningfulContent trait for SearchResult
|
||||
let empty_result = SearchResult {
|
||||
items: vec![],
|
||||
total_record_count: 0,
|
||||
};
|
||||
assert!(!empty_result.has_content());
|
||||
|
||||
let non_empty_result = SearchResult {
|
||||
items: vec![MediaItem {
|
||||
id: "1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
item_type: "Audio".to_string(),
|
||||
server_id: "server1".to_string(),
|
||||
parent_id: None,
|
||||
library_id: None,
|
||||
overview: None,
|
||||
genres: None,
|
||||
production_year: None,
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
runtime_ticks: None,
|
||||
primary_image_tag: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
album_name: None,
|
||||
album_artist: None,
|
||||
artists: None,
|
||||
artist_items: None,
|
||||
index_number: None,
|
||||
parent_index_number: None,
|
||||
series_id: None,
|
||||
series_name: None,
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
people: None,
|
||||
}],
|
||||
total_record_count: 1,
|
||||
};
|
||||
assert!(non_empty_result.has_content());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_deserialize_complete() {
|
||||
// Test that Person deserializes correctly with all fields (PascalCase from Jellyfin API)
|
||||
let json = r#"{
|
||||
"Id": "person123",
|
||||
"Name": "Tom Hanks",
|
||||
"Type": "Actor",
|
||||
"Role": "Lead Actor",
|
||||
"PrimaryImageTag": "tag456"
|
||||
}"#;
|
||||
|
||||
let result: Result<Person, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok(), "Failed to deserialize: {:?}", result.err());
|
||||
|
||||
let person = result.unwrap();
|
||||
assert_eq!(person.id, "person123");
|
||||
assert_eq!(person.name, "Tom Hanks");
|
||||
assert_eq!(person.person_type, "Actor");
|
||||
assert_eq!(person.role, Some("Lead Actor".to_string()));
|
||||
assert_eq!(person.primary_image_tag, Some("tag456".to_string()));
|
||||
|
||||
// Verify serialization uses camelCase for frontend
|
||||
let serialized = serde_json::to_string(&person).expect("Failed to serialize");
|
||||
assert!(serialized.contains(r#""type":"Actor""#), "Serialized form should use 'type' not 'Type'");
|
||||
assert!(serialized.contains(r#""id":"person123""#));
|
||||
assert!(serialized.contains(r#""primaryImageTag":"tag456""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_deserialize_minimal() {
|
||||
// Test that Person deserializes with missing optional fields (uses defaults)
|
||||
let json = r#"{
|
||||
"Id": "person456",
|
||||
"Name": "Meryl Streep",
|
||||
"Type": "Actress"
|
||||
}"#;
|
||||
|
||||
let result: Result<Person, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let person = result.unwrap();
|
||||
assert_eq!(person.id, "person456");
|
||||
assert_eq!(person.name, "Meryl Streep");
|
||||
assert_eq!(person.person_type, "Actress");
|
||||
assert_eq!(person.role, None);
|
||||
assert_eq!(person.primary_image_tag, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_array_deserialize() {
|
||||
// Test deserializing array of Person objects (common in Jellyfin API)
|
||||
let json = r#"[
|
||||
{"Id": "actor1", "Name": "Actor One", "Type": "Actor"},
|
||||
{"Id": "director1", "Name": "Director One", "Type": "Director", "Role": "Director"}
|
||||
]"#;
|
||||
|
||||
let result: Result<Vec<Person>, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let people = result.unwrap();
|
||||
assert_eq!(people.len(), 2);
|
||||
assert_eq!(people[0].name, "Actor One");
|
||||
assert_eq!(people[1].person_type, "Director");
|
||||
assert_eq!(people[1].role, Some("Director".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_item_with_people() {
|
||||
// Test that MediaItem correctly deserializes with people array (from API in PascalCase)
|
||||
let json = r#"{
|
||||
"id": "movie1",
|
||||
"name": "Test Movie",
|
||||
"type": "Movie",
|
||||
"serverId": "server1",
|
||||
"people": [
|
||||
{"Id": "actor1", "Name": "John Doe", "Type": "Actor"},
|
||||
{"Id": "director1", "Name": "Jane Smith", "Type": "Director"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: Result<MediaItem, _> = serde_json::from_str(json);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let item = result.unwrap();
|
||||
let people = item.people.expect("Expected people array");
|
||||
assert_eq!(people.len(), 2);
|
||||
assert_eq!(people[0].name, "John Doe");
|
||||
assert_eq!(people[0].person_type, "Actor");
|
||||
assert_eq!(people[1].person_type, "Director");
|
||||
|
||||
// Verify that when serialized to frontend, it uses camelCase
|
||||
let serialized = serde_json::to_string(&item).expect("Failed to serialize");
|
||||
let re_parsed: serde_json::Value = serde_json::from_str(&serialized).expect("Failed to parse serialized");
|
||||
let people_array = re_parsed["people"].as_array().expect("people should be array");
|
||||
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
|
||||
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Session polling manager for remote playback control.
|
||||
//!
|
||||
//! Manages background polling of Jellyfin sessions with dynamic frequency adjustment
|
||||
//! based on playback mode and UI state. Eliminates duplicate pollers across browser tabs.
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::player::PlayerEventEmitter;
|
||||
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
|
||||
|
||||
/// Hint for adjusting poll frequency based on UI state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PollingHint {
|
||||
/// CastButton is active and needs frequent updates (500ms)
|
||||
CastActive,
|
||||
/// CastButton is in discovery mode (15s)
|
||||
CastDiscovery,
|
||||
/// No special hint, use mode-based frequency (default)
|
||||
Normal,
|
||||
}
|
||||
|
||||
/// Manages background polling of Jellyfin sessions
|
||||
pub struct SessionPollerManager {
|
||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||
playback_mode_manager: Arc<PlaybackModeManager>,
|
||||
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
||||
|
||||
// Polling state
|
||||
is_running: Arc<AtomicBool>,
|
||||
current_hint: Arc<RwLock<PollingHint>>,
|
||||
current_interval_ms: Arc<AtomicU64>,
|
||||
|
||||
// Thread handle (for cleanup)
|
||||
thread_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl SessionPollerManager {
|
||||
/// Create a new SessionPollerManager
|
||||
pub fn new(
|
||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||
playback_mode_manager: Arc<PlaybackModeManager>,
|
||||
) -> Self {
|
||||
Self {
|
||||
jellyfin_client,
|
||||
playback_mode_manager,
|
||||
event_emitter: Arc::new(Mutex::new(None)),
|
||||
is_running: Arc::new(AtomicBool::new(false)),
|
||||
current_hint: Arc::new(RwLock::new(PollingHint::Normal)),
|
||||
current_interval_ms: Arc::new(AtomicU64::new(10000)), // Default 10s
|
||||
thread_handle: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set event emitter for broadcasting session updates
|
||||
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
||||
*self.event_emitter.lock().unwrap() = Some(emitter);
|
||||
}
|
||||
|
||||
/// Start the background polling thread
|
||||
pub fn start(&self) {
|
||||
if self.is_running.swap(true, Ordering::Relaxed) {
|
||||
warn!("[SessionPoller] Already running, ignoring start request");
|
||||
return;
|
||||
}
|
||||
|
||||
info!("[SessionPoller] Starting session polling");
|
||||
|
||||
// Clone Arc references for the thread
|
||||
let client = self.jellyfin_client.clone();
|
||||
let mode_manager = self.playback_mode_manager.clone();
|
||||
let emitter = self.event_emitter.clone();
|
||||
let is_running = self.is_running.clone();
|
||||
let hint = self.current_hint.clone();
|
||||
let interval_ms = self.current_interval_ms.clone();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
// Create Tokio runtime for async operations in this thread
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
while is_running.load(Ordering::Relaxed) {
|
||||
// Calculate poll interval based on mode and hint
|
||||
let new_interval = Self::calculate_interval(
|
||||
&mode_manager.get_mode(),
|
||||
*hint.read().unwrap(),
|
||||
);
|
||||
|
||||
interval_ms.store(new_interval, Ordering::Relaxed);
|
||||
|
||||
debug!("[SessionPoller] Polling with interval: {}ms", new_interval);
|
||||
|
||||
// Fetch sessions
|
||||
let sessions_result = rt.block_on(async {
|
||||
let client_opt = client.lock().unwrap().clone();
|
||||
match client_opt {
|
||||
Some(c) => c.get_sessions().await,
|
||||
None => {
|
||||
debug!("[SessionPoller] Jellyfin client not configured, skipping poll");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Emit event if successful
|
||||
match sessions_result {
|
||||
Ok(sessions) => {
|
||||
debug!("[SessionPoller] Fetched {} sessions", sessions.len());
|
||||
if let Some(em) = emitter.lock().unwrap().as_ref() {
|
||||
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
|
||||
sessions,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[SessionPoller] Failed to fetch sessions: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for the calculated interval
|
||||
thread::sleep(Duration::from_millis(new_interval));
|
||||
}
|
||||
|
||||
info!("[SessionPoller] Polling thread stopped");
|
||||
});
|
||||
|
||||
*self.thread_handle.lock().unwrap() = Some(handle);
|
||||
}
|
||||
|
||||
/// Stop the polling thread
|
||||
pub fn stop(&self) {
|
||||
info!("[SessionPoller] Stopping session polling");
|
||||
self.is_running.store(false, Ordering::Relaxed);
|
||||
|
||||
// Join the thread if possible (don't block indefinitely)
|
||||
if let Some(handle) = self.thread_handle.lock().unwrap().take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set UI hint for polling frequency adjustment
|
||||
pub fn set_polling_hint(&self, hint: PollingHint) {
|
||||
debug!("[SessionPoller] Setting polling hint: {:?}", hint);
|
||||
*self.current_hint.write().unwrap() = hint;
|
||||
}
|
||||
|
||||
/// Calculate polling interval based on mode and hint
|
||||
fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
|
||||
match hint {
|
||||
PollingHint::CastActive => 500, // Very fast for active control
|
||||
PollingHint::CastDiscovery => 15000, // Slow discovery
|
||||
PollingHint::Normal => {
|
||||
match mode {
|
||||
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
|
||||
PlaybackMode::Local | PlaybackMode::Idle => 10000, // Default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually trigger a poll (for frontend refresh button)
|
||||
pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
|
||||
let client = self.jellyfin_client.lock().unwrap().clone()
|
||||
.ok_or("Jellyfin client not configured")?;
|
||||
|
||||
client.get_sessions().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionPollerManager {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Test polling interval calculation for different modes and hints
|
||||
#[test]
|
||||
fn test_calculate_interval() {
|
||||
// CastActive hint should always be 500ms regardless of mode
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastActive),
|
||||
500
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastActive),
|
||||
500
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(
|
||||
&PlaybackMode::Remote { session_id: "test".to_string() },
|
||||
PollingHint::CastActive
|
||||
),
|
||||
500
|
||||
);
|
||||
|
||||
// CastDiscovery hint should always be 15s regardless of mode
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastDiscovery),
|
||||
15000
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastDiscovery),
|
||||
15000
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(
|
||||
&PlaybackMode::Remote { session_id: "test".to_string() },
|
||||
PollingHint::CastDiscovery
|
||||
),
|
||||
15000
|
||||
);
|
||||
|
||||
// Normal hint should depend on mode
|
||||
// Idle and Local modes -> 10s
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::Normal),
|
||||
10000
|
||||
);
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::Normal),
|
||||
10000
|
||||
);
|
||||
// Remote mode -> 2s
|
||||
assert_eq!(
|
||||
SessionPollerManager::calculate_interval(
|
||||
&PlaybackMode::Remote { session_id: "test".to_string() },
|
||||
PollingHint::Normal
|
||||
),
|
||||
2000
|
||||
);
|
||||
}
|
||||
|
||||
/// Test PollingHint enum equality
|
||||
#[test]
|
||||
fn test_polling_hint_equality() {
|
||||
assert_eq!(PollingHint::Normal, PollingHint::Normal);
|
||||
assert_eq!(PollingHint::CastActive, PollingHint::CastActive);
|
||||
assert_eq!(PollingHint::CastDiscovery, PollingHint::CastDiscovery);
|
||||
|
||||
assert_ne!(PollingHint::Normal, PollingHint::CastActive);
|
||||
assert_ne!(PollingHint::CastActive, PollingHint::CastDiscovery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Volume normalization levels matching Spotify's presets
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VolumeLevel {
|
||||
/// Louder output (-11 LUFS)
|
||||
Loud,
|
||||
/// Default level (-14 LUFS)
|
||||
#[default]
|
||||
Normal,
|
||||
/// Quieter output (-23 LUFS)
|
||||
Quiet,
|
||||
}
|
||||
|
||||
impl VolumeLevel {
|
||||
/// Get the target LUFS value for this volume level
|
||||
pub fn target_lufs(&self) -> f32 {
|
||||
match self {
|
||||
VolumeLevel::Loud => -11.0,
|
||||
VolumeLevel::Normal => -14.0,
|
||||
VolumeLevel::Quiet => -23.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio playback settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AudioSettings {
|
||||
/// Crossfade duration in seconds (0 = disabled, max 12)
|
||||
pub crossfade_duration: f32,
|
||||
/// Enable gapless playback between tracks
|
||||
pub gapless_playback: bool,
|
||||
/// Enable volume normalization
|
||||
pub normalize_volume: bool,
|
||||
/// Target volume level for normalization
|
||||
pub volume_level: VolumeLevel,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
crossfade_duration: 0.0,
|
||||
gapless_playback: true,
|
||||
normalize_volume: false,
|
||||
volume_level: VolumeLevel::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioSettings {
|
||||
/// Clamp crossfade duration to valid range (0-12 seconds)
|
||||
pub fn with_crossfade_clamped(mut self) -> Self {
|
||||
self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Video playback settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VideoSettings {
|
||||
/// Enable auto-play of next episode (with countdown)
|
||||
pub auto_play_next_episode: bool,
|
||||
/// Countdown duration in seconds before auto-play (5-30 seconds)
|
||||
pub auto_play_countdown_seconds: u32,
|
||||
}
|
||||
|
||||
impl Default for VideoSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_play_next_episode: true,
|
||||
auto_play_countdown_seconds: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoSettings {
|
||||
/// Clamp countdown duration to valid range (5-30 seconds)
|
||||
pub fn with_countdown_clamped(mut self) -> Self {
|
||||
self.auto_play_countdown_seconds = self.auto_play_countdown_seconds.clamp(5, 30);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_settings() {
|
||||
let settings = AudioSettings::default();
|
||||
assert_eq!(settings.crossfade_duration, 0.0);
|
||||
assert!(settings.gapless_playback);
|
||||
assert!(!settings.normalize_volume);
|
||||
assert_eq!(settings.volume_level, VolumeLevel::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_level_lufs() {
|
||||
assert_eq!(VolumeLevel::Loud.target_lufs(), -11.0);
|
||||
assert_eq!(VolumeLevel::Normal.target_lufs(), -14.0);
|
||||
assert_eq!(VolumeLevel::Quiet.target_lufs(), -23.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crossfade_clamping() {
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 20.0,
|
||||
..Default::default()
|
||||
}
|
||||
.with_crossfade_clamped();
|
||||
assert_eq!(settings.crossfade_duration, 12.0);
|
||||
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: -5.0,
|
||||
..Default::default()
|
||||
}
|
||||
.with_crossfade_clamped();
|
||||
assert_eq!(settings.crossfade_duration, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_serialization() {
|
||||
let settings = AudioSettings {
|
||||
crossfade_duration: 5.0,
|
||||
gapless_playback: true,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
assert!(json.contains("\"crossfadeDuration\":5.0"));
|
||||
assert!(json.contains("\"gaplessPlayback\":true"));
|
||||
assert!(json.contains("\"normalizeVolume\":true"));
|
||||
assert!(json.contains("\"volumeLevel\":\"loud\""));
|
||||
|
||||
let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.crossfade_duration, 5.0);
|
||||
assert_eq!(parsed.volume_level, VolumeLevel::Loud);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_default_settings() {
|
||||
let settings = VideoSettings::default();
|
||||
assert!(settings.auto_play_next_episode);
|
||||
assert_eq!(settings.auto_play_countdown_seconds, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_countdown_clamping() {
|
||||
let settings = VideoSettings {
|
||||
auto_play_countdown_seconds: 60,
|
||||
..Default::default()
|
||||
}
|
||||
.with_countdown_clamped();
|
||||
assert_eq!(settings.auto_play_countdown_seconds, 30);
|
||||
|
||||
let settings = VideoSettings {
|
||||
auto_play_countdown_seconds: 2,
|
||||
..Default::default()
|
||||
}
|
||||
.with_countdown_clamped();
|
||||
assert_eq!(settings.auto_play_countdown_seconds, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_settings_serialization() {
|
||||
let settings = VideoSettings {
|
||||
auto_play_next_episode: false,
|
||||
auto_play_countdown_seconds: 15,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
assert!(json.contains("\"autoPlayNextEpisode\":false"));
|
||||
assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
|
||||
|
||||
let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
|
||||
assert!(!parsed.auto_play_next_episode);
|
||||
assert_eq!(parsed.auto_play_countdown_seconds, 15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Database service abstraction layer
|
||||
//!
|
||||
//! This module provides an async database interface that abstracts away
|
||||
//! the underlying database implementation. This makes it easy to:
|
||||
//! - Switch between sync (rusqlite) and async (tokio-rusqlite) implementations
|
||||
//! - Prevent blocking the async runtime with synchronous database calls
|
||||
//! - Test with different database backends
|
||||
//! - Migrate to other database systems in the future
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Database query result type
|
||||
pub type DbResult<T> = Result<T, String>;
|
||||
|
||||
/// Represents a database query that can be executed
|
||||
#[derive(Clone)]
|
||||
pub struct Query {
|
||||
pub sql: String,
|
||||
pub params: Vec<QueryParam>,
|
||||
}
|
||||
|
||||
/// Query parameter types supported by the database
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum QueryParam {
|
||||
String(String),
|
||||
Int(i32),
|
||||
Int64(i64),
|
||||
Float(f64),
|
||||
#[allow(dead_code)]
|
||||
Bool(bool),
|
||||
Null,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
pub fn new(sql: impl Into<String>) -> Self {
|
||||
Self {
|
||||
sql: sql.into(),
|
||||
params: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_params(sql: impl Into<String>, params: Vec<QueryParam>) -> Self {
|
||||
Self {
|
||||
sql: sql.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database service trait - abstraction over database operations
|
||||
#[async_trait]
|
||||
pub trait DatabaseService: Send + Sync {
|
||||
/// Execute a query that doesn't return results (INSERT, UPDATE, DELETE)
|
||||
async fn execute(&self, query: Query) -> DbResult<usize>;
|
||||
|
||||
/// Execute a batch of SQL statements (for migrations)
|
||||
#[allow(dead_code)]
|
||||
async fn execute_batch(&self, sql: &str) -> DbResult<()>;
|
||||
|
||||
/// Query a single row
|
||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
||||
|
||||
/// Query a single optional row
|
||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
||||
|
||||
/// Query multiple rows
|
||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
||||
|
||||
/// Run a transaction with multiple operations
|
||||
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||
T: Send + 'static;
|
||||
|
||||
/// Get the row ID of the most recent successful INSERT
|
||||
async fn last_insert_rowid(&self) -> DbResult<i64>;
|
||||
}
|
||||
|
||||
/// Transaction handle for batching multiple operations
|
||||
pub struct Transaction<'a> {
|
||||
conn: &'a Connection,
|
||||
}
|
||||
|
||||
impl<'a> Transaction<'a> {
|
||||
pub fn new(conn: &'a Connection) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
pub fn execute(&mut self, query: Query) -> DbResult<usize> {
|
||||
execute_query(self.conn, query)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rusqlite-based database service implementation
|
||||
///
|
||||
/// This implementation wraps synchronous rusqlite operations in tokio::task::spawn_blocking
|
||||
/// to prevent blocking the async runtime.
|
||||
pub struct RusqliteService {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl RusqliteService {
|
||||
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DatabaseService for RusqliteService {
|
||||
async fn execute(&self, query: Query) -> DbResult<usize> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
execute_query(&conn, query)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn execute_batch(&self, sql: &str) -> DbResult<()> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
let sql = sql.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
conn.execute_batch(&sql)
|
||||
.map_err(|e| format!("Execute batch failed: {}", e))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_one(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_optional(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
query_many(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
|
||||
let mut transaction = Transaction::new(&conn);
|
||||
let result = f(&mut transaction);
|
||||
|
||||
match result {
|
||||
Ok(value) => {
|
||||
conn.execute("COMMIT", [])
|
||||
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(e) => {
|
||||
conn.execute("ROLLBACK", [])
|
||||
.map_err(|e| format!("Failed to rollback transaction: {}", e))?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for executing queries synchronously
|
||||
|
||||
fn execute_query(conn: &Connection, query: Query) -> DbResult<usize> {
|
||||
let params = convert_params(&query.params);
|
||||
conn.execute(&query.sql, params_from_iter(params.iter()))
|
||||
.map_err(|e| format!("Execute failed: {}", e))
|
||||
}
|
||||
|
||||
fn query_one<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<T>
|
||||
where
|
||||
F: Fn(&Row) -> SqliteResult<T>,
|
||||
{
|
||||
let params = convert_params(&query.params);
|
||||
conn.query_row(&query.sql, params_from_iter(params.iter()), mapper)
|
||||
.map_err(|e| format!("Query one failed: {}", e))
|
||||
}
|
||||
|
||||
fn query_optional<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Option<T>>
|
||||
where
|
||||
F: Fn(&Row) -> SqliteResult<T>,
|
||||
{
|
||||
match query_one(conn, query, mapper) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_many<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||
where
|
||||
F: Fn(&Row) -> SqliteResult<T>,
|
||||
{
|
||||
let params = convert_params(&query.params);
|
||||
let mut stmt = conn
|
||||
.prepare(&query.sql)
|
||||
.map_err(|e| format!("Prepare failed: {}", e))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params_from_iter(params.iter()), mapper)
|
||||
.map_err(|e| format!("Query map failed: {}", e))?;
|
||||
|
||||
rows.collect::<SqliteResult<Vec<T>>>()
|
||||
.map_err(|e| format!("Collect failed: {}", e))
|
||||
}
|
||||
|
||||
/// Convert QueryParam to rusqlite::types::Value
|
||||
fn convert_params(params: &[QueryParam]) -> Vec<rusqlite::types::Value> {
|
||||
params
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
QueryParam::String(s) => rusqlite::types::Value::Text(s.clone()),
|
||||
QueryParam::Int(i) => rusqlite::types::Value::Integer(*i as i64),
|
||||
QueryParam::Int64(i) => rusqlite::types::Value::Integer(*i),
|
||||
QueryParam::Float(f) => rusqlite::types::Value::Real(*f),
|
||||
QueryParam::Bool(b) => rusqlite::types::Value::Integer(if *b { 1 } else { 0 }),
|
||||
QueryParam::Null => rusqlite::types::Value::Null,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_query() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO test (name) VALUES (?)",
|
||||
vec![QueryParam::String("Alice".to_string())],
|
||||
);
|
||||
|
||||
let rows = service.execute(query).await.unwrap();
|
||||
assert_eq!(rows, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_one() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::new("SELECT name FROM test WHERE id = 1");
|
||||
let name: String = service
|
||||
.query_one(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(name, "Bob");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_many() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO test (name) VALUES ('Alice')", [])
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::new("SELECT name FROM test ORDER BY id");
|
||||
let names: Vec<String> = service
|
||||
.query_many(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(names, vec!["Alice", "Bob"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_optional() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let query = Query::new("SELECT name FROM test WHERE id = 999");
|
||||
let result: Option<String> = service
|
||||
.query_optional(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transaction() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
.unwrap();
|
||||
|
||||
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
||||
|
||||
let result = service
|
||||
.transaction(|tx| {
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT INTO test (name) VALUES (?)",
|
||||
vec![QueryParam::String("Alice".to_string())],
|
||||
))?;
|
||||
tx.execute(Query::with_params(
|
||||
"INSERT INTO test (name) VALUES (?)",
|
||||
vec![QueryParam::String("Bob".to_string())],
|
||||
))?;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify both rows were inserted
|
||||
let query = Query::new("SELECT COUNT(*) FROM test");
|
||||
let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
//! Offline storage module using SQLite
|
||||
//!
|
||||
//! Provides local caching of Jellyfin metadata, download management,
|
||||
//! and offline mutation queue for sync-back operations.
|
||||
|
||||
pub mod db_service;
|
||||
pub mod models;
|
||||
pub mod schema;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use log::{debug, error, info};
|
||||
use rusqlite::{Connection, Result as SqliteResult};
|
||||
|
||||
use schema::MIGRATIONS;
|
||||
pub use db_service::{DatabaseService, RusqliteService};
|
||||
|
||||
/// Database connection wrapper with thread-safe access
|
||||
pub struct Database {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open or create the database at a specific path
|
||||
pub fn open(path: &PathBuf) -> SqliteResult<Self> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
// Enable foreign keys
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
|
||||
// Enable WAL mode for better concurrent access
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
path: path.clone(),
|
||||
};
|
||||
|
||||
// Run migrations
|
||||
db.migrate()?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for testing)
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> SqliteResult<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
|
||||
// Enable foreign keys
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
path: PathBuf::from(":memory:"),
|
||||
};
|
||||
|
||||
// Run migrations
|
||||
db.migrate()?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Get connection (for testing)
|
||||
#[cfg(test)]
|
||||
pub fn connection(&self) -> Arc<Mutex<Connection>> {
|
||||
Arc::clone(&self.conn)
|
||||
}
|
||||
|
||||
/// Run all pending migrations
|
||||
pub fn migrate(&self) -> SqliteResult<()> {
|
||||
info!("Starting database migrations...");
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
// Create migrations table if it doesn't exist
|
||||
debug!("Creating _migrations table if it doesn't exist...");
|
||||
match conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
) {
|
||||
Ok(_) => debug!("_migrations table ready"),
|
||||
Err(e) => {
|
||||
error!("Failed to create _migrations table: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get applied migrations
|
||||
debug!("Querying applied migrations...");
|
||||
let mut stmt = conn.prepare("SELECT name FROM _migrations")?;
|
||||
let applied: Vec<String> = stmt
|
||||
.query_map([], |row: &rusqlite::Row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
debug!("Found {} applied migrations", applied.len());
|
||||
|
||||
// Apply pending migrations
|
||||
for (name, sql) in MIGRATIONS {
|
||||
if !applied.contains(&name.to_string()) {
|
||||
info!("Applying migration: {}", name);
|
||||
match conn.execute_batch(sql) {
|
||||
Ok(_) => {
|
||||
info!("Successfully applied migration: {}", name);
|
||||
match conn.execute(
|
||||
"INSERT INTO _migrations (name) VALUES (?1)",
|
||||
[name],
|
||||
) {
|
||||
Ok(_) => debug!("Recorded migration: {}", name),
|
||||
Err(e) => {
|
||||
error!("Failed to record migration {}: {}", name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to apply migration {}: {}", name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("Skipping already applied migration: {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
info!("All migrations completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a database service for async-safe operations
|
||||
///
|
||||
/// This wraps all blocking database operations in spawn_blocking to prevent
|
||||
/// freezing the async runtime.
|
||||
pub fn service(&self) -> RusqliteService {
|
||||
RusqliteService::new(Arc::clone(&self.conn))
|
||||
}
|
||||
|
||||
/// Get the database file path
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Get database file size in bytes
|
||||
pub fn file_size(&self) -> Option<u64> {
|
||||
std::fs::metadata(&self.path).ok().map(|m| m.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::params;
|
||||
|
||||
#[test]
|
||||
fn test_open_in_memory() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
assert_eq!(db.path().to_str(), Some(":memory:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrations_run() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Check that tables exist
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items'")
|
||||
.unwrap();
|
||||
let exists: Option<String> = stmt.query_row([], |row: &rusqlite::Row| row.get(0)).ok();
|
||||
assert!(exists.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_tables_created() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let expected_tables = [
|
||||
"servers",
|
||||
"users",
|
||||
"libraries",
|
||||
"items",
|
||||
"media_streams",
|
||||
"user_data",
|
||||
"downloads",
|
||||
"sync_queue",
|
||||
"thumbnails",
|
||||
"playlists",
|
||||
"playlist_items",
|
||||
];
|
||||
|
||||
for table in expected_tables {
|
||||
let exists: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
|
||||
[table],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(exists.is_some(), "Table '{}' should exist", table);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fts_table_created() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let exists: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
assert!(exists.is_some(), "FTS table 'items_fts' should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_crud() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Insert a server
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["server1", "My Server", "http://localhost:8096", "10.8.0"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read it back
|
||||
let (name, url): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT name, url FROM servers WHERE id = ?1",
|
||||
["server1"],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "My Server");
|
||||
assert_eq!(url, "http://localhost:8096");
|
||||
|
||||
// Update it
|
||||
conn.execute(
|
||||
"UPDATE servers SET name = ?1 WHERE id = ?2",
|
||||
params!["Updated Server", "server1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let name: String = conn
|
||||
.query_row("SELECT name FROM servers WHERE id = ?1", ["server1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(name, "Updated Server");
|
||||
|
||||
// Delete it
|
||||
conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
|
||||
.unwrap();
|
||||
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_crud() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create a server first (foreign key)
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test Server", "http://localhost:8096"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert a user
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["user1", "server1", "admin", 1],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read it back
|
||||
let (username, is_active): (String, i32) = conn
|
||||
.query_row(
|
||||
"SELECT username, is_active FROM users WHERE id = ?1",
|
||||
["user1"],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(username, "admin");
|
||||
assert_eq!(is_active, 1);
|
||||
|
||||
// Update is_active
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 0 WHERE id = ?1",
|
||||
["user1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let is_active: i32 = conn
|
||||
.query_row("SELECT is_active FROM users WHERE id = ?1", ["user1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(is_active, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cascade_delete_server_removes_users() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create server and user
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test Server", "http://localhost:8096"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify user exists
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE server_id = ?1", ["server1"], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// Delete server
|
||||
conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
|
||||
.unwrap();
|
||||
|
||||
// User should be deleted via CASCADE
|
||||
let count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_item_insert_and_fts_search() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create server first
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test Server", "http://localhost:8096"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert an item
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type, overview, album_name, artists)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
"item1",
|
||||
"server1",
|
||||
"Bohemian Rhapsody",
|
||||
"Audio",
|
||||
"A legendary rock song",
|
||||
"A Night at the Opera",
|
||||
"[\"Queen\"]"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search via FTS
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT i.name FROM items i
|
||||
JOIN items_fts ON i.rowid = items_fts.rowid
|
||||
WHERE items_fts MATCH ?1",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Search by song name
|
||||
let result: Option<String> = stmt
|
||||
.query_row(["Bohemian"], |row: &rusqlite::Row| row.get(0))
|
||||
.ok();
|
||||
assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
|
||||
|
||||
// Search by album name
|
||||
let result: Option<String> = stmt
|
||||
.query_row(["Opera"], |row: &rusqlite::Row| row.get(0))
|
||||
.ok();
|
||||
assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
|
||||
|
||||
// Search by artist
|
||||
let result: Option<String> = stmt
|
||||
.query_row(["Queen"], |row: &rusqlite::Row| row.get(0))
|
||||
.ok();
|
||||
assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_data_playback_position() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Setup: server, user, item
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test", "http://localhost"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["item1", "server1", "Test Movie", "Movie"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert user data with playback position
|
||||
conn.execute(
|
||||
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, is_played)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["user1", "item1", 12345678900_i64, 0],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read back
|
||||
let (position, is_played): (i64, i32) = conn
|
||||
.query_row(
|
||||
"SELECT playback_position_ticks, is_played FROM user_data
|
||||
WHERE user_id = ?1 AND item_id = ?2",
|
||||
["user1", "item1"],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(position, 12345678900);
|
||||
assert_eq!(is_played, 0);
|
||||
|
||||
// Update to mark as played
|
||||
conn.execute(
|
||||
"UPDATE user_data SET is_played = 1, playback_position_ticks = 0
|
||||
WHERE user_id = ?1 AND item_id = ?2",
|
||||
["user1", "item1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let is_played: i32 = conn
|
||||
.query_row(
|
||||
"SELECT is_played FROM user_data WHERE user_id = ?1 AND item_id = ?2",
|
||||
["user1", "item1"],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(is_played, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_queue_operations() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Setup
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test", "http://localhost"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Queue a sync operation
|
||||
conn.execute(
|
||||
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
"user1",
|
||||
"mark_favorite",
|
||||
"item123",
|
||||
r#"{"favorite": true}"#,
|
||||
"pending"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Get pending operations
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT operation, item_id FROM sync_queue WHERE status = 'pending'")
|
||||
.unwrap();
|
||||
|
||||
let ops: Vec<(String, String)> = stmt
|
||||
.query_map([], |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0].0, "mark_favorite");
|
||||
assert_eq!(ops[0].1, "item123");
|
||||
|
||||
// Mark as completed
|
||||
conn.execute(
|
||||
"UPDATE sync_queue SET status = 'completed' WHERE item_id = ?1",
|
||||
["item123"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pending_count: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sync_queue WHERE status = 'pending'",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pending_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_downloads_table() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Setup
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test", "http://localhost"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["item1", "server1", "Test Song", "Audio"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Queue a download
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status, progress)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params!["item1", "user1", "/data/downloads/test.mp3", "pending", 0.0],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Update progress
|
||||
conn.execute(
|
||||
"UPDATE downloads SET status = 'downloading', progress = 0.5
|
||||
WHERE item_id = ?1",
|
||||
["item1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (status, progress): (String, f64) = conn
|
||||
.query_row(
|
||||
"SELECT status, progress FROM downloads WHERE item_id = ?1",
|
||||
["item1"],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, "downloading");
|
||||
assert!((progress - 0.5).abs() < 0.001);
|
||||
|
||||
// Complete download
|
||||
conn.execute(
|
||||
"UPDATE downloads SET status = 'completed', progress = 1.0
|
||||
WHERE item_id = ?1",
|
||||
["item1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status: String = conn
|
||||
.query_row(
|
||||
"SELECT status FROM downloads WHERE item_id = ?1",
|
||||
["item1"],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrations_idempotent() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
// Run migrations again - should not fail
|
||||
let result = db.migrate();
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Tables should still exist
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let count: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_active_user_deactivation() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create two servers
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Server 1", "http://server1.com"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server2", "Server 2", "http://server2.com"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create users on different servers
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active, last_login_at)
|
||||
VALUES (?1, ?2, ?3, 1, '2024-01-01 10:00:00')",
|
||||
params!["user1", "server1", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active, last_login_at)
|
||||
VALUES (?1, ?2, ?3, 1, '2024-01-01 11:00:00')",
|
||||
params!["user2", "server2", "admin"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Initially both users are active (simulating the old bug)
|
||||
let active_count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(active_count, 2);
|
||||
|
||||
// Now simulate setting user1 as active (global deactivation)
|
||||
conn.execute("UPDATE users SET is_active = 0", [])
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
|
||||
["user1"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Only one user should be active now
|
||||
let active_count: i32 = conn
|
||||
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(active_count, 1);
|
||||
|
||||
// And it should be user1
|
||||
let active_user: String = conn
|
||||
.query_row(
|
||||
"SELECT id FROM users WHERE is_active = 1",
|
||||
[],
|
||||
|row: &rusqlite::Row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(active_user, "user1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_session_query_ordering() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// Create server
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
|
||||
params!["server1", "Test Server", "http://localhost:8096"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create users with different login times
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active, last_login_at)
|
||||
VALUES (?1, ?2, ?3, 0, '2024-01-01 10:00:00')",
|
||||
params!["user1", "server1", "old_user"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active, last_login_at)
|
||||
VALUES (?1, ?2, ?3, 1, '2024-01-01 12:00:00')",
|
||||
params!["user2", "server1", "recent_user"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Query for active user ordered by last_login_at DESC (simulating storage_get_active_session)
|
||||
let (user_id, username): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT u.id, u.username FROM users u
|
||||
JOIN servers s ON u.server_id = s.id
|
||||
WHERE u.is_active = 1
|
||||
ORDER BY u.last_login_at DESC
|
||||
LIMIT 1",
|
||||
[],
|
||||
|row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user_id, "user2");
|
||||
assert_eq!(username, "recent_user");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Database model structs
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Playlist item entry
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaylistItem {
|
||||
pub id: Option<i64>,
|
||||
pub playlist_id: String,
|
||||
pub item_id: String,
|
||||
pub sort_order: i32,
|
||||
pub added_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
//! Database schema and migrations
|
||||
|
||||
/// List of migrations to apply in order.
|
||||
/// Each migration is a tuple of (name, sql).
|
||||
pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("001_initial_schema", MIGRATION_001),
|
||||
("002_remove_access_token", MIGRATION_002),
|
||||
("003_relax_user_data_constraints", MIGRATION_003),
|
||||
("004_enhance_downloads", MIGRATION_004),
|
||||
("005_relax_downloads_fk", MIGRATION_005),
|
||||
("006_downloads_metadata", MIGRATION_006),
|
||||
("007_cache_metadata", MIGRATION_007),
|
||||
("008_video_downloads", MIGRATION_008),
|
||||
("009_people_tables", MIGRATION_009),
|
||||
("010_playback_context", MIGRATION_010),
|
||||
("011_user_player_settings", MIGRATION_011),
|
||||
("012_download_source", MIGRATION_012),
|
||||
("013_downloads_item_status_index", MIGRATION_013),
|
||||
("014_series_audio_preferences", MIGRATION_014),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
const MIGRATION_001: &str = r#"
|
||||
-- Jellyfin servers the user has connected to
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
version TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_connected_at TEXT
|
||||
);
|
||||
|
||||
-- User accounts on Jellyfin servers
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
access_token TEXT,
|
||||
is_active INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TEXT,
|
||||
UNIQUE(server_id, username)
|
||||
);
|
||||
|
||||
-- Libraries/views from Jellyfin
|
||||
CREATE TABLE IF NOT EXISTS libraries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
collection_type TEXT,
|
||||
image_tag TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
synced_at TEXT,
|
||||
UNIQUE(server_id, id)
|
||||
);
|
||||
|
||||
-- Media items (movies, shows, episodes, albums, songs, artists)
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
|
||||
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
|
||||
|
||||
-- Core metadata
|
||||
name TEXT NOT NULL,
|
||||
sort_name TEXT,
|
||||
original_title TEXT,
|
||||
item_type TEXT NOT NULL, -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
|
||||
|
||||
-- Media info
|
||||
overview TEXT,
|
||||
tagline TEXT,
|
||||
genres TEXT, -- JSON array
|
||||
tags TEXT, -- JSON array
|
||||
studios TEXT, -- JSON array
|
||||
|
||||
-- For episodes
|
||||
series_id TEXT,
|
||||
series_name TEXT,
|
||||
season_id TEXT,
|
||||
season_name TEXT,
|
||||
index_number INTEGER, -- Episode number
|
||||
parent_index_number INTEGER, -- Season number
|
||||
|
||||
-- For music
|
||||
album_id TEXT,
|
||||
album_name TEXT,
|
||||
album_artist TEXT,
|
||||
artists TEXT, -- JSON array
|
||||
|
||||
-- Dates
|
||||
premiere_date TEXT,
|
||||
production_year INTEGER,
|
||||
date_created TEXT,
|
||||
|
||||
-- Runtime (ticks)
|
||||
runtime_ticks INTEGER,
|
||||
|
||||
-- Images
|
||||
primary_image_tag TEXT,
|
||||
backdrop_image_tags TEXT, -- JSON array
|
||||
|
||||
-- Ratings
|
||||
community_rating REAL,
|
||||
official_rating TEXT,
|
||||
|
||||
-- Sync metadata
|
||||
synced_at TEXT,
|
||||
etag TEXT,
|
||||
|
||||
UNIQUE(server_id, id)
|
||||
);
|
||||
|
||||
-- Full-text search index for items
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
|
||||
name,
|
||||
overview,
|
||||
album_name,
|
||||
album_artist,
|
||||
artists,
|
||||
series_name,
|
||||
content='items',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
|
||||
-- Triggers to keep FTS index in sync
|
||||
CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
|
||||
INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
|
||||
VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
|
||||
INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
|
||||
VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
|
||||
INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
|
||||
VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
|
||||
INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
|
||||
VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
|
||||
END;
|
||||
|
||||
-- Media streams (audio/subtitle tracks)
|
||||
CREATE TABLE IF NOT EXISTS media_streams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
stream_index INTEGER NOT NULL,
|
||||
stream_type TEXT NOT NULL, -- Audio, Subtitle, Video
|
||||
codec TEXT,
|
||||
language TEXT,
|
||||
display_title TEXT,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
is_forced INTEGER DEFAULT 0,
|
||||
is_external INTEGER DEFAULT 0,
|
||||
path TEXT, -- For external subtitles
|
||||
UNIQUE(item_id, stream_index)
|
||||
);
|
||||
|
||||
-- User-specific data (watch progress, favorites)
|
||||
CREATE TABLE IF NOT EXISTS user_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
|
||||
-- Playback state
|
||||
playback_position_ticks INTEGER DEFAULT 0,
|
||||
play_count INTEGER DEFAULT 0,
|
||||
is_played INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
last_played_at TEXT,
|
||||
|
||||
-- Sync status
|
||||
synced_at TEXT,
|
||||
pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync
|
||||
|
||||
UNIQUE(user_id, item_id)
|
||||
);
|
||||
|
||||
-- Downloaded media files
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- File info
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
|
||||
-- Download state
|
||||
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
||||
progress REAL DEFAULT 0, -- 0.0 to 1.0
|
||||
|
||||
-- Transcoding options used
|
||||
bitrate INTEGER,
|
||||
container TEXT,
|
||||
|
||||
-- Timestamps
|
||||
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
|
||||
-- Error tracking
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
UNIQUE(item_id, user_id)
|
||||
);
|
||||
|
||||
-- Offline mutation queue (changes to sync back to server)
|
||||
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Operation details
|
||||
operation TEXT NOT NULL, -- mark_played, mark_favorite, update_progress, etc.
|
||||
item_id TEXT,
|
||||
payload TEXT, -- JSON data for the operation
|
||||
|
||||
-- Queue state
|
||||
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TEXT,
|
||||
|
||||
-- Error tracking
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- Cached thumbnails
|
||||
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc.
|
||||
image_tag TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(item_id, image_type, image_tag)
|
||||
);
|
||||
|
||||
-- User playlists (local + synced)
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
is_local INTEGER DEFAULT 0, -- 1 for local-only playlists
|
||||
jellyfin_id TEXT, -- NULL for local-only
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
-- Playlist items
|
||||
CREATE TABLE IF NOT EXISTS playlist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL,
|
||||
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(playlist_id, item_id)
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
|
||||
"#;
|
||||
|
||||
/// Migration to remove access_token column from users table
|
||||
/// Tokens are now stored in the system keyring (or encrypted file fallback)
|
||||
const MIGRATION_002: &str = r#"
|
||||
-- Remove access_token column from users table
|
||||
-- Tokens are now stored in secure storage (system keyring)
|
||||
|
||||
-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
|
||||
CREATE TABLE IF NOT EXISTS users_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
is_active INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TEXT,
|
||||
UNIQUE(server_id, username)
|
||||
);
|
||||
|
||||
-- Copy existing data (excluding access_token)
|
||||
INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
|
||||
SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE IF EXISTS users;
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
"#;
|
||||
|
||||
/// Migration to relax foreign key constraints on user_data table
|
||||
/// Allows tracking playback progress for items not yet synced to local database
|
||||
const MIGRATION_003: &str = r#"
|
||||
-- Recreate user_data table without foreign key constraint on item_id
|
||||
-- This allows tracking playback progress for items that haven't been synced locally yet
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_data_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
|
||||
|
||||
-- Playback state
|
||||
playback_position_ticks INTEGER DEFAULT 0,
|
||||
play_count INTEGER DEFAULT 0,
|
||||
is_played INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
last_played_at TEXT,
|
||||
|
||||
-- Sync status
|
||||
synced_at TEXT,
|
||||
pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync
|
||||
|
||||
UNIQUE(user_id, item_id)
|
||||
);
|
||||
|
||||
-- Copy existing data
|
||||
INSERT OR IGNORE INTO user_data_new (id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync)
|
||||
SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE IF EXISTS user_data;
|
||||
ALTER TABLE user_data_new RENAME TO user_data;
|
||||
|
||||
-- Recreate index
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
|
||||
"#;
|
||||
|
||||
/// Migration to enhance downloads table with priority and bytes_downloaded
|
||||
const MIGRATION_004: &str = r#"
|
||||
-- Add priority column for queue ordering
|
||||
ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
|
||||
|
||||
-- Add bytes_downloaded for resume support
|
||||
ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
|
||||
|
||||
-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_queue
|
||||
ON downloads(status, priority DESC, queued_at ASC)
|
||||
WHERE status IN ('pending', 'downloading');
|
||||
"#;
|
||||
|
||||
/// Migration to relax foreign key constraint on downloads.item_id
|
||||
/// Allows downloading items that haven't been synced to local database yet
|
||||
const MIGRATION_005: &str = r#"
|
||||
-- Recreate downloads table without foreign key constraint on item_id
|
||||
-- This allows downloading items that haven't been synced locally yet
|
||||
|
||||
CREATE TABLE IF NOT EXISTS downloads_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- File info
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
|
||||
-- Download state
|
||||
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
||||
progress REAL DEFAULT 0, -- 0.0 to 1.0
|
||||
|
||||
-- Transcoding options used
|
||||
bitrate INTEGER,
|
||||
container TEXT,
|
||||
|
||||
-- Timestamps
|
||||
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
|
||||
-- Error tracking
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
|
||||
-- Priority and progress tracking (from migration 004)
|
||||
priority INTEGER DEFAULT 0,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
|
||||
UNIQUE(item_id, user_id)
|
||||
);
|
||||
|
||||
-- Copy existing data
|
||||
INSERT OR IGNORE INTO downloads_new (
|
||||
id, item_id, user_id, file_path, file_size, mime_type, status, progress,
|
||||
bitrate, container, queued_at, started_at, completed_at, error_message,
|
||||
retry_count, priority, bytes_downloaded
|
||||
)
|
||||
SELECT
|
||||
id, item_id, user_id, file_path, file_size, mime_type, status, progress,
|
||||
bitrate, container, queued_at, started_at, completed_at, error_message,
|
||||
retry_count, priority, bytes_downloaded
|
||||
FROM downloads;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE IF EXISTS downloads;
|
||||
ALTER TABLE downloads_new RENAME TO downloads;
|
||||
|
||||
-- Recreate indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_queue
|
||||
ON downloads(status, priority DESC, queued_at ASC)
|
||||
WHERE status IN ('pending', 'downloading');
|
||||
"#;
|
||||
|
||||
/// Migration to store item metadata directly in downloads table
|
||||
/// This eliminates dependency on items table being synced and fixes UUID display issues
|
||||
const MIGRATION_006: &str = r#"
|
||||
-- Add columns to store item metadata directly in downloads
|
||||
-- This ensures correct display even when items aren't synced locally
|
||||
ALTER TABLE downloads ADD COLUMN item_name TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN artist_name TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN album_name TEXT;
|
||||
"#;
|
||||
|
||||
/// Migration to enhance thumbnail caching with LRU eviction support
|
||||
/// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
|
||||
/// - Adds last_accessed for LRU eviction
|
||||
/// - Adds file_size for cache limit tracking
|
||||
/// - Creates cache_settings table for configurable limits
|
||||
const MIGRATION_007: &str = r#"
|
||||
-- Recreate thumbnails table without foreign key constraint on item_id
|
||||
-- and add LRU eviction support columns
|
||||
CREATE TABLE IF NOT EXISTS thumbnails_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
|
||||
image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc.
|
||||
image_tag TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
file_size INTEGER DEFAULT 0, -- Size in bytes for cache limit tracking
|
||||
cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed TEXT DEFAULT CURRENT_TIMESTAMP, -- For LRU eviction
|
||||
UNIQUE(item_id, image_type, image_tag)
|
||||
);
|
||||
|
||||
-- Copy existing data (if any)
|
||||
INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
|
||||
SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
|
||||
|
||||
-- Drop old table and rename new one
|
||||
DROP TABLE IF EXISTS thumbnails;
|
||||
ALTER TABLE thumbnails_new RENAME TO thumbnails;
|
||||
|
||||
-- Create indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
|
||||
|
||||
-- Cache settings table for configurable limits
|
||||
CREATE TABLE IF NOT EXISTS cache_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert default settings
|
||||
INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824'); -- 1GB default
|
||||
INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
|
||||
"#;
|
||||
|
||||
/// Migration to add video download support and item pinning
|
||||
/// - Adds video-specific metadata columns to downloads (series/episode info, quality preset)
|
||||
/// - Adds media_type to distinguish audio vs video downloads
|
||||
/// - Adds is_pinned column to items table for protecting metadata from cache clear
|
||||
const MIGRATION_008: &str = r#"
|
||||
-- Add video-specific metadata columns to downloads
|
||||
ALTER TABLE downloads ADD COLUMN series_name TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN season_name TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
|
||||
ALTER TABLE downloads ADD COLUMN season_number INTEGER;
|
||||
ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
|
||||
ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
|
||||
|
||||
-- Add pinning support to items table
|
||||
-- Pinned items are protected from cache clear operations
|
||||
ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
|
||||
|
||||
-- Index for efficiently finding pinned items
|
||||
CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
|
||||
|
||||
-- Index for efficiently querying downloads by series
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
|
||||
|
||||
-- Index for filtering by media type
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
|
||||
"#;
|
||||
|
||||
/// Migration to add people/cast caching support
|
||||
/// - Creates people table for caching actor/director/writer/etc info
|
||||
/// - Creates item_people junction table for many-to-many relationships
|
||||
/// - Adds indexes for efficient queries
|
||||
const MIGRATION_009: &str = r#"
|
||||
-- People table for caching cast/crew members
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
overview TEXT,
|
||||
primary_image_tag TEXT,
|
||||
premiere_date TEXT, -- Birth date
|
||||
end_date TEXT, -- Death date
|
||||
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(server_id, id)
|
||||
);
|
||||
|
||||
-- Item-Person association table (many-to-many)
|
||||
-- Stores which people appear in which items, along with role info
|
||||
CREATE TABLE IF NOT EXISTS item_people (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
person_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
person_type TEXT NOT NULL, -- Actor, Director, Writer, Producer, Composer, etc.
|
||||
role TEXT, -- Character name for actors
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(item_id, person_id, person_type)
|
||||
);
|
||||
|
||||
-- Indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
|
||||
"#;
|
||||
|
||||
/// Migration to add playback context tracking
|
||||
/// - Adds playback_context_type column to track if user played a container or single item
|
||||
/// - Adds playback_context_id column to store the container ID (album/playlist)
|
||||
/// - Adds index for efficient recently played queries
|
||||
const MIGRATION_010: &str = r#"
|
||||
-- Add playback context tracking to user_data
|
||||
-- Tracks whether user played a container (album/playlist) or single item
|
||||
ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
|
||||
ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
|
||||
|
||||
-- Index for efficient recently played queries
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_last_played
|
||||
ON user_data(user_id, last_played_at DESC)
|
||||
WHERE last_played_at IS NOT NULL;
|
||||
"#;
|
||||
|
||||
/// Migration to add user-specific player settings
|
||||
/// - Creates user_player_settings table for autoplay and audio preferences
|
||||
/// - Note: Sleep timer state is NOT persisted (cancelled on app close)
|
||||
/// - Autoplay settings control next episode behavior
|
||||
/// - Audio settings for crossfade, gapless playback, and volume normalization
|
||||
const MIGRATION_011: &str = r#"
|
||||
-- User-specific player settings (autoplay and audio settings)
|
||||
-- Sleep timer is NOT persisted here (maintained in-memory only)
|
||||
CREATE TABLE IF NOT EXISTS user_player_settings (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Autoplay settings
|
||||
autoplay_next_episode INTEGER DEFAULT 1, -- 1 = enabled, 0 = disabled
|
||||
autoplay_countdown_seconds INTEGER DEFAULT 10, -- 5-30 seconds
|
||||
|
||||
-- Audio settings (crossfade, normalization)
|
||||
crossfade_duration REAL DEFAULT 0.0, -- 0-12 seconds
|
||||
gapless_playback INTEGER DEFAULT 1,
|
||||
normalize_volume INTEGER DEFAULT 0,
|
||||
volume_level TEXT DEFAULT 'normal', -- 'loud', 'normal', 'quiet'
|
||||
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for efficient user settings lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
|
||||
"#;
|
||||
|
||||
/// Migration to track download source (user-initiated vs auto-cached)
|
||||
/// - Adds download_source column to distinguish manual downloads from auto-caching
|
||||
/// - Enables color-coded UI display
|
||||
const MIGRATION_012: &str = r#"
|
||||
-- Add download source tracking
|
||||
-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
|
||||
ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
|
||||
|
||||
-- Index for filtering by source
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
|
||||
"#;
|
||||
|
||||
/// Migration to add composite index for offline mode filtering
|
||||
/// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
|
||||
/// - Significantly improves performance when filtering items by download status
|
||||
const MIGRATION_013: &str = r#"
|
||||
-- Add composite index for offline mode filtering
|
||||
-- This speeds up queries that join items with downloads to show only downloaded content
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
|
||||
"#;
|
||||
|
||||
/// Migration to add series audio track preferences
|
||||
/// - Stores user's preferred audio track per series
|
||||
/// - Matches tracks by display title and language across episodes
|
||||
/// - Falls back to default track if preferred track not found
|
||||
const MIGRATION_014: &str = r#"
|
||||
-- Series-specific audio track preferences
|
||||
-- When user changes audio track for an episode, remember preference for the series
|
||||
CREATE TABLE IF NOT EXISTS series_audio_preferences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
series_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
|
||||
-- Audio track info for matching across episodes
|
||||
audio_track_display_title TEXT,
|
||||
audio_track_language TEXT,
|
||||
audio_track_index INTEGER,
|
||||
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY (user_id, series_id, server_id)
|
||||
);
|
||||
|
||||
-- Index for efficient lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
|
||||
ON series_audio_preferences(user_id, series_id);
|
||||
"#;
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Thumbnail cache manager with LRU eviction
|
||||
|
||||
use log::error;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// Configuration for the thumbnail cache
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheConfig {
|
||||
/// Maximum cache size in bytes (0 = unlimited)
|
||||
pub max_size_bytes: u64,
|
||||
/// Subdirectory name for cached thumbnails
|
||||
pub cache_subdir: String,
|
||||
/// Whether caching is enabled
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for CacheConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_size_bytes: 1024 * 1024 * 1024, // 1GB
|
||||
cache_subdir: "thumbnails".to_string(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thumbnail cache with LRU eviction
|
||||
pub struct ThumbnailCache {
|
||||
config: Arc<Mutex<CacheConfig>>,
|
||||
cache_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ThumbnailCache {
|
||||
/// Create a new thumbnail cache
|
||||
pub fn new(app_data_dir: PathBuf, config: CacheConfig) -> Self {
|
||||
let cache_dir = app_data_dir.join(&config.cache_subdir);
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
|
||||
error!("Failed to create thumbnail cache directory: {}", e);
|
||||
}
|
||||
|
||||
Self {
|
||||
config: Arc::new(Mutex::new(config)),
|
||||
cache_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if caching is enabled
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.config.lock().map(|c| c.enabled).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Get cached thumbnail path, or None if not cached
|
||||
/// Updates last_accessed timestamp for LRU tracking
|
||||
pub async fn get_cached_path(
|
||||
&self,
|
||||
db: Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
image_type: &str,
|
||||
tag: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let query = Query::with_params(
|
||||
"SELECT file_path FROM thumbnails
|
||||
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
let path_str: String = db.query_optional(query, |row| row.get(0)).await.ok()??;
|
||||
let path = PathBuf::from(&path_str);
|
||||
|
||||
if path.exists() {
|
||||
// Update last_accessed for LRU tracking
|
||||
let update_query = Query::with_params(
|
||||
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
|
||||
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
],
|
||||
);
|
||||
let _ = db.execute(update_query).await;
|
||||
Some(path)
|
||||
} else {
|
||||
// Clean up stale database entry
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM thumbnails
|
||||
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
],
|
||||
);
|
||||
let _ = db.execute(delete_query).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Save thumbnail to cache
|
||||
pub async fn save_thumbnail(
|
||||
&self,
|
||||
db: Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
image_type: &str,
|
||||
tag: &str,
|
||||
data: &[u8],
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
) -> Result<PathBuf, String> {
|
||||
if !self.is_enabled() {
|
||||
return Err("Thumbnail caching is disabled".to_string());
|
||||
}
|
||||
|
||||
// Generate safe filename
|
||||
let safe_tag = tag.replace(|c: char| !c.is_alphanumeric(), "_");
|
||||
let filename = format!("{}_{}_{}.jpg", item_id, image_type, safe_tag);
|
||||
let file_path = self.cache_dir.join(&filename);
|
||||
|
||||
// Ensure we have space (evict LRU items if needed)
|
||||
self.ensure_space(db.clone(), data.len() as u64).await?;
|
||||
|
||||
// Write file to disk
|
||||
std::fs::write(&file_path, data).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
|
||||
// Insert/update database entry
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO thumbnails (item_id, image_type, image_tag, file_path, width, height, file_size, last_accessed, cached_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(item_id, image_type, image_tag) DO UPDATE SET
|
||||
file_path = excluded.file_path,
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
file_size = excluded.file_size,
|
||||
last_accessed = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(image_type.to_string()),
|
||||
QueryParam::String(tag.to_string()),
|
||||
QueryParam::String(file_path.to_string_lossy().to_string()),
|
||||
width.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
height.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
||||
QueryParam::Int64(data.len() as i64),
|
||||
],
|
||||
);
|
||||
|
||||
db.execute(query)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save to database: {}", e))?;
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Ensure there's enough space by evicting LRU items if needed
|
||||
async fn ensure_space(&self, db: Arc<RusqliteService>, needed_bytes: u64) -> Result<(), String> {
|
||||
let max_size = {
|
||||
let config = self.config.lock().map_err(|e| e.to_string())?;
|
||||
config.max_size_bytes
|
||||
};
|
||||
|
||||
if max_size == 0 {
|
||||
return Ok(()); // Unlimited
|
||||
}
|
||||
|
||||
let current_size = self.get_cache_size(db.clone()).await;
|
||||
|
||||
if current_size + needed_bytes <= max_size {
|
||||
return Ok(()); // Enough space
|
||||
}
|
||||
|
||||
// Need to evict LRU items
|
||||
let to_free = (current_size + needed_bytes).saturating_sub(max_size);
|
||||
self.evict_lru(db, to_free).await
|
||||
}
|
||||
|
||||
/// Evict least recently used items to free up space
|
||||
async fn evict_lru(&self, db: Arc<RusqliteService>, to_free: u64) -> Result<(), String> {
|
||||
let mut freed: u64 = 0;
|
||||
|
||||
// Get items ordered by last_accessed (oldest first)
|
||||
let query = Query::new(
|
||||
"SELECT id, file_path, file_size FROM thumbnails
|
||||
ORDER BY last_accessed ASC",
|
||||
);
|
||||
|
||||
let items: Vec<(i64, String, i64)> = db
|
||||
.query_many(query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for (id, path, size) in items {
|
||||
if freed >= to_free {
|
||||
break;
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Delete from database
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM thumbnails WHERE id = ?",
|
||||
vec![QueryParam::Int64(id)],
|
||||
);
|
||||
let _ = db.execute(delete_query).await;
|
||||
|
||||
freed += size as u64;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current total cache size in bytes
|
||||
pub async fn get_cache_size(&self, db: Arc<RusqliteService>) -> u64 {
|
||||
let query = Query::new("SELECT COALESCE(SUM(file_size), 0) FROM thumbnails");
|
||||
|
||||
db.query_one(query, |row| row.get::<_, i64>(0))
|
||||
.await
|
||||
.unwrap_or(0) as u64
|
||||
}
|
||||
|
||||
/// Get count of cached items
|
||||
pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
|
||||
let query = Query::new("SELECT COUNT(*) FROM thumbnails");
|
||||
|
||||
db.query_one(query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get the current cache limit in bytes
|
||||
pub async fn get_limit(&self, db: Arc<RusqliteService>) -> u64 {
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM cache_settings WHERE key = ?",
|
||||
vec![QueryParam::String("image_cache_limit_bytes".to_string())],
|
||||
);
|
||||
|
||||
db.query_optional(query, |row| row.get::<_, String>(0))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1024 * 1024 * 1024) // 1GB default
|
||||
}
|
||||
|
||||
/// Set the cache limit in bytes
|
||||
pub async fn set_limit(&self, db: Arc<RusqliteService>, limit_bytes: u64) -> Result<(), String> {
|
||||
// Update database setting
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
vec![
|
||||
QueryParam::String("image_cache_limit_bytes".to_string()),
|
||||
QueryParam::String(limit_bytes.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
db.execute(query)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update setting: {}", e))?;
|
||||
|
||||
// Update in-memory config
|
||||
if let Ok(mut config) = self.config.lock() {
|
||||
config.max_size_bytes = limit_bytes;
|
||||
}
|
||||
|
||||
// If new limit is lower, evict to comply
|
||||
let current_size = self.get_cache_size(db.clone()).await;
|
||||
if limit_bytes > 0 && current_size > limit_bytes {
|
||||
let to_free = current_size - limit_bytes;
|
||||
self.evict_lru(db, to_free).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear all cached thumbnails
|
||||
pub async fn clear_cache(&self, db: Arc<RusqliteService>) -> Result<(), String> {
|
||||
// Get all file paths
|
||||
let query = Query::new("SELECT file_path FROM thumbnails");
|
||||
|
||||
let paths: Vec<String> = db
|
||||
.query_many(query, |row| row.get(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Delete files from disk
|
||||
for path in paths {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// Clear database
|
||||
let delete_query = Query::new("DELETE FROM thumbnails");
|
||||
db.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to clear database: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete cached thumbnail for a specific item
|
||||
pub async fn delete_item(&self, db: Arc<RusqliteService>, item_id: &str) -> Result<(), String> {
|
||||
// Get file paths for this item
|
||||
let query = Query::with_params(
|
||||
"SELECT file_path FROM thumbnails WHERE item_id = ?",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
);
|
||||
|
||||
let paths: Vec<String> = db
|
||||
.query_many(query, |row| row.get(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Delete files
|
||||
for path in paths {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
let delete_query = Query::with_params(
|
||||
"DELETE FROM thumbnails WHERE item_id = ?",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
);
|
||||
db.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete from database: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
use rusqlite::Connection;
|
||||
use std::io::Write;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_test_db() -> (Arc<RusqliteService>, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
|
||||
// Create tables
|
||||
conn.execute(
|
||||
"CREATE TABLE thumbnails (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
image_type TEXT NOT NULL,
|
||||
image_tag TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
file_size INTEGER DEFAULT 0,
|
||||
cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(item_id, image_type, image_tag)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE cache_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
|
||||
(db_service, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
assert!(cache.cache_dir.exists());
|
||||
assert!(cache.is_enabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_get_thumbnail() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
// Save a thumbnail
|
||||
let data = b"fake image data";
|
||||
let path = cache
|
||||
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, Some(100), Some(100))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
|
||||
// Get cached path
|
||||
let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await;
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap(), path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_miss() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let cached = cache.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1").await;
|
||||
assert!(cached.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_lru_eviction() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let config = CacheConfig {
|
||||
max_size_bytes: 100, // Very small limit
|
||||
..Default::default()
|
||||
};
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), config);
|
||||
|
||||
// Add items that exceed limit
|
||||
let data = vec![0u8; 60];
|
||||
cache
|
||||
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Second item should trigger eviction
|
||||
cache
|
||||
.save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// First item should be evicted
|
||||
let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await;
|
||||
assert!(cached.is_none());
|
||||
|
||||
// Second item should exist
|
||||
let cached = cache.get_cached_path(conn.clone(), "item2", "Primary", "tag2").await;
|
||||
assert!(cached.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_cache() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let data = b"fake image data";
|
||||
cache
|
||||
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
cache
|
||||
.save_thumbnail(conn.clone(), "item2", "Primary", "tag2", data, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cache.get_item_count(conn.clone()).await, 2);
|
||||
|
||||
cache.clear_cache(conn.clone()).await.unwrap();
|
||||
|
||||
assert_eq!(cache.get_item_count(conn.clone()).await, 0);
|
||||
assert_eq!(cache.get_cache_size(conn.clone()).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_limit() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
// Save some thumbnails
|
||||
let data = vec![0u8; 50];
|
||||
cache
|
||||
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
cache
|
||||
.save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Set a limit smaller than current size
|
||||
cache.set_limit(conn.clone(), 60).await.unwrap();
|
||||
|
||||
// Some items should be evicted
|
||||
let size = cache.get_cache_size(conn.clone()).await;
|
||||
assert!(size <= 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Thumbnail caching with LRU eviction
|
||||
//!
|
||||
//! This module handles caching image thumbnails from Jellyfin servers with:
|
||||
//! - Lazy caching (cache as viewed)
|
||||
//! - LRU (Least Recently Used) eviction when storage limit reached
|
||||
//! - Configurable storage limits
|
||||
//! - SQLite-backed cache metadata
|
||||
|
||||
pub mod cache;
|
||||
pub mod worker;
|
||||
|
||||
pub use cache::{CacheConfig, ThumbnailCache};
|
||||
pub use worker::ThumbnailWorker;
|
||||
|
||||
/// Statistics about the thumbnail cache
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThumbnailCacheStats {
|
||||
pub total_size_bytes: u64,
|
||||
pub item_count: i64,
|
||||
pub limit_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_stats_serialization() {
|
||||
let stats = ThumbnailCacheStats {
|
||||
total_size_bytes: 1024,
|
||||
item_count: 10,
|
||||
limit_bytes: 1073741824,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&stats).unwrap();
|
||||
assert!(json.contains("\"totalSizeBytes\":1024"));
|
||||
assert!(json.contains("\"itemCount\":10"));
|
||||
assert!(json.contains("\"limitBytes\":1073741824"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Thumbnail download worker
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Worker for downloading thumbnails from Jellyfin server
|
||||
pub struct ThumbnailWorker {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ThumbnailWorker {
|
||||
/// Create a new thumbnail worker
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Download a thumbnail from URL
|
||||
pub async fn download(&self, url: &str) -> Result<Vec<u8>, ThumbnailDownloadError> {
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ThumbnailDownloadError::Network(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(ThumbnailDownloadError::Http(status.as_u16()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ThumbnailDownloadError::Network(e.to_string()))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Download with retry logic
|
||||
pub async fn download_with_retry(
|
||||
&self,
|
||||
url: &str,
|
||||
max_retries: u32,
|
||||
) -> Result<Vec<u8>, ThumbnailDownloadError> {
|
||||
let mut last_error = ThumbnailDownloadError::Network("No attempts made".to_string());
|
||||
|
||||
for attempt in 0..=max_retries {
|
||||
match self.download(url).await {
|
||||
Ok(data) => return Ok(data),
|
||||
Err(e) if e.is_retryable() && attempt < max_retries => {
|
||||
last_error = e;
|
||||
// Simple exponential backoff: 100ms, 200ms, 400ms
|
||||
let delay = Duration::from_millis(100 * (1 << attempt));
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ThumbnailWorker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur during thumbnail download
|
||||
#[derive(Debug)]
|
||||
pub enum ThumbnailDownloadError {
|
||||
Network(String),
|
||||
Http(u16),
|
||||
}
|
||||
|
||||
impl ThumbnailDownloadError {
|
||||
/// Check if this error is retryable
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
ThumbnailDownloadError::Network(_) => true,
|
||||
ThumbnailDownloadError::Http(status) => *status >= 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ThumbnailDownloadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ThumbnailDownloadError::Network(msg) => write!(f, "Network error: {}", msg),
|
||||
ThumbnailDownloadError::Http(status) => write!(f, "HTTP error: {}", status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ThumbnailDownloadError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_retryable() {
|
||||
assert!(ThumbnailDownloadError::Network("timeout".to_string()).is_retryable());
|
||||
assert!(ThumbnailDownloadError::Http(500).is_retryable());
|
||||
assert!(ThumbnailDownloadError::Http(503).is_retryable());
|
||||
assert!(!ThumbnailDownloadError::Http(404).is_retryable());
|
||||
assert!(!ThumbnailDownloadError::Http(400).is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worker_creation() {
|
||||
let _worker = ThumbnailWorker::new();
|
||||
// Just verify it doesn't panic
|
||||
let _default = ThumbnailWorker::default();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Unit conversion and formatting utilities
|
||||
//!
|
||||
//! This module provides centralized conversion functions for:
|
||||
//! - Jellyfin tick-to-seconds conversions
|
||||
//! - Volume normalization (0-1 vs 0-100 ranges)
|
||||
//! - Time formatting for UI display
|
||||
//!
|
||||
//! These utilities eliminate magic numbers and duplicate conversion logic
|
||||
//! across the codebase.
|
||||
|
||||
/// Number of Jellyfin ticks per second (10 million)
|
||||
///
|
||||
/// Jellyfin uses "ticks" for time values where 10,000,000 ticks = 1 second.
|
||||
/// This follows the .NET TimeSpan.Ticks convention.
|
||||
pub const TICKS_PER_SECOND: i64 = 10_000_000;
|
||||
|
||||
/// Convert seconds to Jellyfin ticks
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `seconds` - Time in seconds (e.g., 90.5 for 1 minute 30.5 seconds)
|
||||
///
|
||||
/// # Returns
|
||||
/// Time in Jellyfin ticks (will be rounded down to nearest tick)
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let ticks = seconds_to_ticks(1.5); // 15,000,000 ticks
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn seconds_to_ticks(seconds: f64) -> i64 {
|
||||
(seconds * TICKS_PER_SECOND as f64) as i64
|
||||
}
|
||||
|
||||
/// Convert Jellyfin ticks to seconds
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `ticks` - Time in Jellyfin ticks
|
||||
///
|
||||
/// # Returns
|
||||
/// Time in seconds as floating point
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let seconds = ticks_to_seconds(15_000_000); // 1.5 seconds
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn ticks_to_seconds(ticks: i64) -> f64 {
|
||||
ticks as f64 / TICKS_PER_SECOND as f64
|
||||
}
|
||||
|
||||
/// Convert percentage volume (0-100) to normalized (0.0-1.0)
|
||||
///
|
||||
/// Used when receiving volume from Jellyfin remote sessions or UI controls.
|
||||
/// Values outside the 0-100 range are clamped.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `percent` - Volume as percentage (0 to 100)
|
||||
///
|
||||
/// # Returns
|
||||
/// Normalized volume (0.0 to 1.0)
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let normalized = percent_to_volume(75.0); // 0.75
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn percent_to_volume(percent: f64) -> f64 {
|
||||
percent.clamp(0.0, 100.0) / 100.0
|
||||
}
|
||||
|
||||
/// Convert normalized volume (0.0-1.0) to percentage (0-100)
|
||||
///
|
||||
/// Used when sending volume to Jellyfin remote sessions, MPV, or ExoPlayer.
|
||||
/// Values outside the 0.0-1.0 range are clamped.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `volume` - Normalized volume (0.0 to 1.0)
|
||||
///
|
||||
/// # Returns
|
||||
/// Volume as percentage (0 to 100), floored to integer value
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let percent = volume_to_percent(0.75); // 75.0
|
||||
/// ```
|
||||
#[inline]
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn volume_to_percent(volume: f64) -> f64 {
|
||||
(volume.clamp(0.0, 1.0) * 100.0).floor()
|
||||
}
|
||||
|
||||
/// Format time in seconds to MM:SS display string
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `seconds` - Time in seconds
|
||||
///
|
||||
/// # Returns
|
||||
/// Formatted string like "3:45" or "12:09"
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let formatted = format_time(225.0); // "3:45"
|
||||
/// ```
|
||||
pub fn format_time(seconds: f64) -> String {
|
||||
let mins = (seconds / 60.0).floor() as i64;
|
||||
let secs = (seconds % 60.0).floor() as i64;
|
||||
format!("{}:{:02}", mins, secs)
|
||||
}
|
||||
|
||||
/// 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"
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let short = format_time_long(225.0); // "3:45"
|
||||
/// let long = format_time_long(5025.0); // "1:23:45"
|
||||
/// ```
|
||||
pub fn format_time_long(seconds: f64) -> String {
|
||||
let hours = (seconds / 3600.0).floor() as i64;
|
||||
let mins = ((seconds % 3600.0) / 60.0).floor() as i64;
|
||||
let secs = (seconds % 60.0).floor() as i64;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{}:{:02}:{:02}", hours, mins, secs)
|
||||
} else {
|
||||
format!("{}:{:02}", mins, secs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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), or 0.0 if duration is invalid
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let progress = calculate_progress(45.0, 180.0); // 25.0
|
||||
/// ```
|
||||
pub fn calculate_progress(position: f64, duration: f64) -> f64 {
|
||||
if duration <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
((position / duration) * 100.0).clamp(0.0, 100.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tick_conversion() {
|
||||
assert_eq!(seconds_to_ticks(1.0), 10_000_000);
|
||||
assert_eq!(seconds_to_ticks(0.5), 5_000_000);
|
||||
assert_eq!(ticks_to_seconds(10_000_000), 1.0);
|
||||
assert_eq!(ticks_to_seconds(5_000_000), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_conversion() {
|
||||
assert_eq!(percent_to_volume(100.0), 1.0);
|
||||
assert_eq!(percent_to_volume(50.0), 0.5);
|
||||
assert_eq!(percent_to_volume(0.0), 0.0);
|
||||
assert_eq!(volume_to_percent(1.0), 100.0);
|
||||
assert_eq!(volume_to_percent(0.5), 50.0);
|
||||
assert_eq!(volume_to_percent(0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_clamping() {
|
||||
assert_eq!(percent_to_volume(150.0), 1.0);
|
||||
assert_eq!(percent_to_volume(-10.0), 0.0);
|
||||
assert_eq!(volume_to_percent(1.5), 100.0);
|
||||
assert_eq!(volume_to_percent(-0.5), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_formatting() {
|
||||
assert_eq!(format_time(0.0), "0:00");
|
||||
assert_eq!(format_time(59.0), "0:59");
|
||||
assert_eq!(format_time(60.0), "1:00");
|
||||
assert_eq!(format_time(125.0), "2:05");
|
||||
assert_eq!(format_time(3661.0), "61:01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_formatting_long() {
|
||||
assert_eq!(format_time_long(0.0), "0:00");
|
||||
assert_eq!(format_time_long(59.0), "0:59");
|
||||
assert_eq!(format_time_long(3599.0), "59:59");
|
||||
assert_eq!(format_time_long(3600.0), "1:00:00");
|
||||
assert_eq!(format_time_long(3661.0), "1:01:01");
|
||||
assert_eq!(format_time_long(7384.0), "2:03:04");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_progress_calculation() {
|
||||
assert_eq!(calculate_progress(0.0, 100.0), 0.0);
|
||||
assert_eq!(calculate_progress(50.0, 100.0), 50.0);
|
||||
assert_eq!(calculate_progress(100.0, 100.0), 100.0);
|
||||
assert_eq!(calculate_progress(25.0, 0.0), 0.0); // Invalid duration
|
||||
assert_eq!(calculate_progress(150.0, 100.0), 100.0); // Clamped
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod conversions;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "jellytau",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||