First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
[target.aarch64-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.armv7-linux-androideabi]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.i686-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.x86_64-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[env]
# Point to the NDK for the cc crate and other build scripts
ANDROID_NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
# Set CC/CXX for each Android target (cc crate looks for these)
CC_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
CXX_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang++"
AR_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
CXX_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang++"
AR_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
CXX_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang++"
AR_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
CXX_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang++"
AR_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
+24
View File
@@ -0,0 +1,24 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# Includes Android projects, schemas, and all other generated files
/gen/
# Backup files
**/*.rs.bk
# Build artifacts
*.apk
*.aab
*.ipa
# Android/Gradle (if not using gen/)
.gradle
local.properties
**/android/**/build/
**/android/.gradle/
# macOS
.DS_Store
+5987
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
[package]
name = "jellytau"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "jellytau_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
rand = "0.8"
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
tokio-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
futures-util = "0.3"
async-trait = "0.1"
# SQLite for offline storage
tokio-rusqlite = "0.6"
rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
directories = "5"
# Secure credential storage (system keyring with encrypted file fallback)
keyring = "3"
aes-gcm = "0.10"
base64 = "0.22"
sha2 = "0.10"
getrandom = "0.2"
log = "0.4"
env_logger = "0.11"
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
libc = "0.2"
# Use latest git version for better MPV version compatibility
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
# JNI for Android ExoPlayer integration
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
ndk-context = "0.1"
[dev-dependencies]
tempfile = "3.24.0"
+51
View File
@@ -0,0 +1,51 @@
# ⚠️ IMPORTANT: Android Build File Locations
## Critical Information for Future Development
**DO NOT EDIT FILES IN `src-tauri/gen/android/` DIRECTLY!**
### File Structure
This project has **TWO** sets of Android source files:
1. **`src-tauri/android/`** - **SOURCE FILES** (edit these!)
- This is the template directory
- Changes here need to be copied to the generated directory
2. **`src-tauri/gen/android/`** - **GENERATED BUILD DIRECTORY** (do not edit directly!)
- This is where Gradle actually builds the APK
- Files here may be overwritten during builds
### How to Make Changes to Android Code
When you need to modify Android/Kotlin files:
1. **Edit the files in `src-tauri/android/src/main/java/`**
2. **Build using the provided script (which auto-syncs files)**
```bash
./scripts/build-android.sh
```
The build script automatically runs `./scripts/sync-android-sources.sh` which copies:
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/` → generated directory
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/security/` → generated directory
3. **Manual sync (if needed)**
```bash
./scripts/sync-android-sources.sh
```
### Why This Matters
- If you only edit `src-tauri/gen/android/`, your changes will be lost
- If you only edit `src-tauri/android/`, your changes won't be in the build
- **You must edit both** (or edit source and copy to generated)
### Key Files
Player-related Kotlin files:
- `player/JellyTauPlayer.kt` - Main player implementation
- `player/JellyTauPlaybackService.kt` - MediaSession service for lockscreen controls
- `security/SecureStorage.kt` - Android Keystore integration for secure credential storage
Always check BOTH locations exist and match after making changes!
@@ -0,0 +1,285 @@
package com.dtourolle.jellytau.player
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.*
/**
* JellyTau media player wrapper using ExoPlayer (Media3).
*
* This class is designed to be called from Rust via JNI.
* All player operations are marshalled to the main thread.
*/
@OptIn(UnstableApi::class)
class JellyTauPlayer(context: Context) {
companion object {
/** Position update interval in milliseconds */
private const val POSITION_UPDATE_INTERVAL_MS = 250L
/** Singleton instance for JNI access */
@Volatile
private var instance: JellyTauPlayer? = null
init {
// Load the native library for JNI callbacks
System.loadLibrary("jellytau_lib")
}
/**
* Initialize the player singleton.
* Called from Rust via JNI during Android startup.
*/
@JvmStatic
fun initialize(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = JellyTauPlayer(context.applicationContext)
}
}
}
}
/**
* Get the singleton instance.
* @throws IllegalStateException if not initialized
*/
@JvmStatic
fun getInstance(): JellyTauPlayer {
return instance ?: throw IllegalStateException("JellyTauPlayer not initialized")
}
}
private val mainHandler = Handler(Looper.getMainLooper())
private val exoPlayer: ExoPlayer
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var positionUpdateJob: Job? = null
/** Current media ID being played */
private var currentMediaId: String? = null
init {
// Create ExoPlayer on main thread
exoPlayer = ExoPlayer.Builder(context).build()
// Set up player listener
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
// Media loaded and ready
val duration = exoPlayer.duration / 1000.0
nativeOnMediaLoaded(duration)
val state = if (exoPlayer.isPlaying) "playing" else "paused"
nativeOnStateChanged(state, currentMediaId)
}
Player.STATE_ENDED -> {
// Playback completed
stopPositionUpdates()
nativeOnPlaybackEnded()
}
Player.STATE_BUFFERING -> {
nativeOnBuffering(0)
}
Player.STATE_IDLE -> {
// Player is idle
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
val state = if (isPlaying) "playing" else "paused"
nativeOnStateChanged(state, currentMediaId)
if (isPlaying) {
startPositionUpdates()
} else {
stopPositionUpdates()
}
}
override fun onPlayerError(error: PlaybackException) {
val message = error.message ?: "Unknown playback error"
val recoverable = error.errorCode != PlaybackException.ERROR_CODE_UNSPECIFIED
nativeOnError(message, recoverable)
}
})
}
/**
* Load media from a URL.
* @param url The media URL to load
* @param mediaId The unique ID for this media item
*/
fun load(url: String, mediaId: String) {
mainHandler.post {
currentMediaId = mediaId
val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
}
/**
* Start or resume playback.
*/
fun play() {
mainHandler.post {
exoPlayer.play()
}
}
/**
* Pause playback.
*/
fun pause() {
mainHandler.post {
exoPlayer.pause()
}
}
/**
* Stop playback and release media.
*/
fun stop() {
mainHandler.post {
stopPositionUpdates()
exoPlayer.stop()
exoPlayer.clearMediaItems()
currentMediaId = null
nativeOnStateChanged("idle", null)
}
}
/**
* Seek to a position.
* @param positionSeconds Position in seconds
*/
fun seek(positionSeconds: Double) {
mainHandler.post {
val positionMs = (positionSeconds * 1000).toLong()
exoPlayer.seekTo(positionMs)
}
}
/**
* Set the volume.
* @param volume Volume level from 0.0 to 1.0
*/
fun setVolume(volume: Float) {
mainHandler.post {
exoPlayer.volume = volume.coerceIn(0f, 1f)
nativeOnVolumeChanged(exoPlayer.volume, false)
}
}
/**
* Get the current playback position in seconds.
*/
fun getPosition(): Double {
return exoPlayer.currentPosition / 1000.0
}
/**
* Get the total duration in seconds.
*/
fun getDuration(): Double {
val duration = exoPlayer.duration
return if (duration > 0) duration / 1000.0 else 0.0
}
/**
* Get the current volume.
*/
fun getVolume(): Float {
return exoPlayer.volume
}
/**
* Check if media is currently loaded.
*/
fun isLoaded(): Boolean {
return exoPlayer.playbackState == Player.STATE_READY ||
exoPlayer.playbackState == Player.STATE_BUFFERING
}
/**
* Release player resources.
* Call when the app is closing.
*/
fun release() {
mainHandler.post {
stopPositionUpdates()
coroutineScope.cancel()
exoPlayer.release()
instance = null
}
}
private fun startPositionUpdates() {
positionUpdateJob?.cancel()
positionUpdateJob = coroutineScope.launch {
while (isActive) {
if (exoPlayer.isPlaying) {
val position = exoPlayer.currentPosition / 1000.0
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
nativeOnPositionUpdate(position, duration)
}
delay(POSITION_UPDATE_INTERVAL_MS)
}
}
}
private fun stopPositionUpdates() {
positionUpdateJob?.cancel()
positionUpdateJob = null
}
// Native methods to call back to Rust via JNI
// These will be implemented in the Rust android module
/**
* Called when position updates during playback.
*/
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
/**
* Called when player state changes.
*/
private external fun nativeOnStateChanged(state: String, mediaId: String?)
/**
* Called when media has finished loading.
*/
private external fun nativeOnMediaLoaded(duration: Double)
/**
* Called when playback reaches the end.
*/
private external fun nativeOnPlaybackEnded()
/**
* Called when buffering state changes.
*/
private external fun nativeOnBuffering(percent: Int)
/**
* Called when a playback error occurs.
*/
private external fun nativeOnError(message: String, recoverable: Boolean)
/**
* Called when volume changes.
*/
private external fun nativeOnVolumeChanged(volume: Float, muted: Boolean)
}
+39
View File
@@ -0,0 +1,39 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.dtourolle.jellytau.player"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
getByName("debug") {
}
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-common:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
@@ -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)
}
File diff suppressed because it is too large Load Diff
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"core:path:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+409
View File
@@ -0,0 +1,409 @@
pub mod session_verifier;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::jellyfin::http_client::HttpClient;
use crate::connectivity::ConnectivityMonitor;
pub use session_verifier::SessionVerifier;
/// Server information returned from Jellyfin
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfo {
pub name: String,
pub version: String,
pub id: String,
/// Normalized server URL with protocol and no trailing slash
pub normalized_url: String,
}
/// User information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: String,
pub name: String,
pub server_id: String,
pub primary_image_tag: Option<String>,
}
/// Authentication result
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthResult {
pub user: User,
pub access_token: String,
pub server_id: String,
}
/// Active session for restoration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub user_id: String,
pub username: String,
pub server_id: String,
pub server_url: String,
pub server_name: String,
pub access_token: String,
pub verified: bool,
pub needs_reauth: bool,
}
// Jellyfin API response types (PascalCase from server)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PublicSystemInfo {
server_name: String,
version: String,
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AuthenticateByNameResponse {
user: JellyfinUser,
access_token: String,
server_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinUser {
id: String,
name: String,
server_id: String,
primary_image_tag: Option<String>,
}
/// Authentication manager
pub struct AuthManager {
http_client: Arc<HttpClient>,
current_session: Arc<RwLock<Option<Session>>>,
connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
}
impl AuthManager {
/// Create a new auth manager
pub fn new(http_client: HttpClient) -> Self {
Self {
http_client: Arc::new(http_client),
current_session: Arc::new(RwLock::new(None)),
connectivity_monitor: None,
}
}
/// Set the connectivity monitor (for marking server reachability)
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
self.connectivity_monitor = Some(monitor);
}
/// Normalize and validate server URL
pub fn normalize_url(url: &str) -> String {
let mut normalized = url.trim().to_string();
// Add https:// if no protocol specified
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
normalized = format!("https://{}", normalized);
}
// Remove trailing slash
if normalized.ends_with('/') {
normalized.pop();
}
normalized
}
/// Connect to server and get server info
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
let normalized_url = Self::normalize_url(server_url);
let endpoint = format!("{}/System/Info/Public", normalized_url);
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
Ok(info) => {
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
Ok(ServerInfo {
name: info.server_name,
version: info.version,
id: info.id,
normalized_url,
})
}
Err(e) => {
log::error!("[AuthManager] Failed to connect to server: {}", e);
// Mark server as unreachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_unreachable(Some(e.clone())).await;
}
Err(e)
}
}
}
/// Authenticate by username and password
pub async fn login(
&self,
server_url: &str,
username: &str,
password: &str,
device_id: &str,
) -> Result<AuthResult, String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Users/AuthenticateByName", url);
log::info!("[AuthManager] Authenticating user: {}", username);
// Build auth header for login request
let auth_header = HttpClient::build_auth_header(None, device_id);
// Build request manually for custom headers
let request = self.http_client.client.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", auth_header)
.json(&serde_json::json!({
"Username": username,
"Pw": password,
}))
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
.map_err(|e| format!("Login request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
}
let auth_response: AuthenticateByNameResponse = response.json().await
.map_err(|e| format!("Failed to parse login response: {}", e))?;
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
let user = User {
id: auth_response.user.id,
name: auth_response.user.name,
server_id: auth_response.user.server_id,
primary_image_tag: auth_response.user.primary_image_tag,
};
Ok(AuthResult {
user,
access_token: auth_response.access_token,
server_id: auth_response.server_id,
})
}
/// Verify current session by fetching user info
pub async fn verify_session(
&self,
server_url: &str,
user_id: &str,
access_token: &str,
device_id: &str,
) -> Result<User, String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Users/{}", url, user_id);
log::info!("[AuthManager] Verifying session for user: {}", user_id);
// Build auth header
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request manually for custom headers
let request = self.http_client.client.get(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
.map_err(|e| {
log::warn!("[AuthManager] Session verification failed: {}", e);
format!("Session verification failed: {}", e)
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
// Mark server as unreachable for auth errors
if status.as_u16() == 401 || status.as_u16() == 403 {
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
}
}
return Err(format!("HTTP {}: {}", status, error_text));
}
let user_response: JellyfinUser = response.json().await
.map_err(|e| format!("Failed to parse user response: {}", e))?;
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
Ok(User {
id: user_response.id,
name: user_response.name,
server_id: user_response.server_id,
primary_image_tag: user_response.primary_image_tag,
})
}
/// Logout (call Jellyfin logout endpoint)
pub async fn logout(
&self,
server_url: &str,
access_token: &str,
device_id: &str,
) -> Result<(), String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Sessions/Logout", url);
log::info!("[AuthManager] Logging out");
// Build auth header
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request
let request = self.http_client.client.post(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Don't retry logout - if it fails, we'll still clear local state
match self.http_client.client.execute(request).await {
Ok(response) => {
if response.status().is_success() {
log::info!("[AuthManager] Logout successful");
} else {
log::warn!("[AuthManager] Logout request failed: {}", response.status());
}
}
Err(e) => {
log::warn!("[AuthManager] Logout request failed: {}", e);
}
}
Ok(())
}
/// Get current session
pub async fn get_session(&self) -> Option<Session> {
self.current_session.read().await.clone()
}
/// Set current session
pub async fn set_session(&self, session: Option<Session>) {
*self.current_session.write().await = session;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test URL normalization - adds https:// when missing
///
/// Ensures that URLs without protocol are normalized to https://
/// This prevents "builder error" when constructing HTTP requests.
#[test]
fn test_normalize_url_adds_https() {
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("192.168.1.100:8096"),
"https://192.168.1.100:8096"
);
}
/// Test URL normalization - preserves existing protocol
#[test]
fn test_normalize_url_preserves_protocol() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("http://localhost:8096"),
"http://localhost:8096"
);
}
/// Test URL normalization - removes trailing slash
#[test]
fn test_normalize_url_removes_trailing_slash() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com/"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com/"),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - trims whitespace
#[test]
fn test_normalize_url_trims_whitespace() {
assert_eq!(
AuthManager::normalize_url(" jellyfin.example.com "),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - complex case
///
/// This is the bug that caused the login issue: user enters URL
/// without protocol, it gets stored in DB, then fails when building
/// HTTP requests.
#[test]
fn test_normalize_url_real_world_case() {
// User input: "jellyfin.tourolle.paris"
let input = "jellyfin.tourolle.paris";
let normalized = AuthManager::normalize_url(input);
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
assert!(normalized.starts_with("https://"));
assert!(!normalized.ends_with('/'));
}
}
+158
View File
@@ -0,0 +1,158 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use super::{AuthManager, User};
// Verification interval (5 minutes)
const VERIFICATION_INTERVAL_MS: u64 = 300000;
/// Session verification result event emitted to frontend
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum SessionVerificationEvent {
Verified { user: User },
NeedsReauth { reason: String },
NetworkError { message: String },
}
/// Background session verifier
pub struct SessionVerifier {
auth_manager: Arc<AuthManager>,
is_running: Arc<AtomicBool>,
device_id: String,
app_handle: Option<AppHandle>,
}
impl SessionVerifier {
/// Create a new session verifier
pub fn new(auth_manager: Arc<AuthManager>, device_id: String) -> Self {
Self {
auth_manager,
is_running: Arc::new(AtomicBool::new(false)),
device_id,
app_handle: None,
}
}
/// Set the Tauri app handle for event emission
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.app_handle = Some(app_handle);
}
/// Start periodic session verification
pub async fn start(&self) {
if self.is_running.swap(true, Ordering::SeqCst) {
log::info!("[SessionVerifier] Already running");
return;
}
log::info!("[SessionVerifier] Starting background verification");
let auth_manager = Arc::clone(&self.auth_manager);
let is_running = Arc::clone(&self.is_running);
let device_id = self.device_id.clone();
let app_handle = self.app_handle.clone();
tokio::spawn(async move {
// Initial verification after short delay
tokio::time::sleep(Duration::from_millis(2000)).await;
while is_running.load(Ordering::SeqCst) {
// Get current session
let session = auth_manager.get_session().await;
if let Some(session) = session {
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
// Verify the session
match auth_manager
.verify_session(
&session.server_url,
&session.user_id,
&session.access_token,
&device_id,
)
.await
{
Ok(user) => {
log::info!("[SessionVerifier] Session verified successfully");
// Emit success event
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::Verified { user };
if let Err(e) = app.emit("auth:session-verified", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
// Update session as verified
let mut updated_session = session;
updated_session.verified = true;
updated_session.needs_reauth = false;
auth_manager.set_session(Some(updated_session)).await;
}
Err(e) => {
log::warn!("[SessionVerifier] Verification failed: {}", e);
// Classify error
let is_auth_error = e.contains("401") || e.contains("403");
let is_network_error = e.contains("network")
|| e.contains("timeout")
|| e.contains("connection")
|| e.contains("DNS");
if is_auth_error {
// Token is invalid - need re-authentication
log::warn!("[SessionVerifier] Session requires re-authentication");
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::NeedsReauth {
reason: "Session expired".to_string(),
};
if let Err(e) = app.emit("auth:needs-reauth", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
// Update session
let mut updated_session = session;
updated_session.verified = false;
updated_session.needs_reauth = true;
auth_manager.set_session(Some(updated_session)).await;
} else if is_network_error {
// Network error - keep using cached session
log::info!("[SessionVerifier] Network error during verification, keeping cached session");
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::NetworkError {
message: e.clone(),
};
if let Err(e) = app.emit("auth:network-error", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
} else {
// Unknown error - log but don't invalidate
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
}
}
}
}
// Wait for next verification
tokio::time::sleep(Duration::from_millis(VERIFICATION_INTERVAL_MS)).await;
}
log::info!("[SessionVerifier] Stopped");
});
}
/// Stop periodic verification
pub fn stop(&self) {
log::info!("[SessionVerifier] Stopping background verification");
self.is_running.store(false, Ordering::SeqCst);
}
}
+239
View File
@@ -0,0 +1,239 @@
use std::sync::Arc;
use tauri::State;
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
/// Wrapper for AuthManager to manage in Tauri state
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
/// Wrapper for SessionVerifier to manage in Tauri state
pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerifier>>>);
/// Initialize the auth manager (call on app startup)
/// Restores session from storage if available
#[tauri::command]
pub async fn auth_initialize(
auth_manager: State<'_, AuthManagerWrapper>,
database: State<'_, crate::commands::DatabaseWrapper>,
credentials: State<'_, crate::commands::CredentialStoreWrapper>,
) -> Result<Option<Session>, String> {
// First check if we already have a session in memory
if let Some(session) = auth_manager.0.get_session().await {
return Ok(Some(session));
}
// Try to restore session from storage
log::info!("[AuthManager] Restoring session from storage...");
// Use the existing storage_get_active_session function
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
Ok(Some(session)) => session,
Ok(None) => {
log::info!("[AuthManager] No active session in storage");
return Ok(None);
}
Err(e) => {
log::error!("[AuthManager] Failed to get active session: {}", e);
return Err(e);
}
};
// Create session object from active session with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
let session = Session {
user_id: active_session.user_id,
username: active_session.username,
server_id: active_session.server_id,
server_url: normalized_url,
server_name: active_session.server_name,
access_token: active_session.access_token,
verified: false, // Will be verified in background
needs_reauth: false,
};
// Store in AuthManager
auth_manager.0.set_session(Some(session.clone())).await;
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
Ok(Some(session))
}
/// Connect to a Jellyfin server and get server info
#[tauri::command]
pub async fn auth_connect_to_server(
server_url: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<ServerInfo, String> {
auth_manager.0.connect_to_server(&server_url).await
}
/// Login with username and password
#[tauri::command]
pub async fn auth_login(
server_url: String,
username: String,
password: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
// Create session from auth result with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url);
let session = Session {
user_id: result.user.id.clone(),
username: result.user.name.clone(),
server_id: result.server_id.clone(),
server_url: normalized_url,
server_name: String::new(), // Will be set by frontend
access_token: result.access_token.clone(),
verified: true,
needs_reauth: false,
};
auth_manager.0.set_session(Some(session)).await;
Ok(result)
}
/// Verify current session
#[tauri::command]
pub async fn auth_verify_session(
server_url: String,
user_id: String,
access_token: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<bool, String> {
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
Ok(_) => Ok(true),
Err(e) => {
log::warn!("[AuthCommands] Session verification failed: {}", e);
Ok(false)
}
}
}
/// Logout (clear session and call Jellyfin logout endpoint)
#[tauri::command]
pub async fn auth_logout(
server_url: String,
access_token: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
// Stop session verification
let mut verifier_guard = session_verifier.0.lock().await;
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
drop(verifier_guard);
// Call Jellyfin logout endpoint
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
// Clear session
auth_manager.0.set_session(None).await;
Ok(())
}
/// Get current session
#[tauri::command]
pub async fn auth_get_session(
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<Option<Session>, String> {
Ok(auth_manager.0.get_session().await)
}
/// Set current session (for restoration from storage)
#[tauri::command]
pub async fn auth_set_session(
session: Option<Session>,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<(), String> {
// Normalize the server URL if session is provided
let normalized_session = session.map(|mut s| {
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
s
});
auth_manager.0.set_session(normalized_session).await;
Ok(())
}
/// Start background session verification
#[tauri::command]
pub async fn auth_start_verification(
device_id: String,
app_handle: tauri::AppHandle,
auth_manager: State<'_, AuthManagerWrapper>,
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
let mut verifier_guard = session_verifier.0.lock().await;
// Stop existing verifier if any
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
// Get AuthManager Arc
let manager = auth_manager.0.clone();
// Create new verifier
let mut verifier = SessionVerifier::new(manager, device_id);
verifier.set_app_handle(app_handle);
verifier.start().await;
*verifier_guard = Some(verifier);
Ok(())
}
/// Stop background session verification
#[tauri::command]
pub async fn auth_stop_verification(
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
let mut verifier_guard = session_verifier.0.lock().await;
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
Ok(())
}
/// Re-authenticate with password (when session expired)
#[tauri::command]
pub async fn auth_reauthenticate(
password: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
// Get current session to extract server_url and username
let session = auth_manager.0.get_session().await
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
// Re-login with stored credentials
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
// Update session with new token
let updated_session = Session {
user_id: result.user.id.clone(),
username: result.user.name.clone(),
server_id: result.server_id.clone(),
server_url: session.server_url,
server_name: session.server_name,
access_token: result.access_token.clone(),
verified: true,
needs_reauth: false,
};
auth_manager.0.set_session(Some(updated_session)).await;
Ok(result)
}
+76
View File
@@ -0,0 +1,76 @@
use std::sync::Arc;
use tauri::State;
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
/// Wrapper for ConnectivityMonitor managed state
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
/// Check if the server is currently reachable
#[tauri::command]
pub async fn connectivity_check_server(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<bool, String> {
let monitor = state.0.lock().await;
Ok(monitor.check_reachability().await)
}
/// Set the server URL and trigger an immediate check
#[tauri::command]
pub async fn connectivity_set_server_url(
url: String,
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.set_server_url(url).await;
Ok(())
}
/// Get the current connectivity status
#[tauri::command]
pub async fn connectivity_get_status(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<ConnectivityStatus, String> {
let monitor = state.0.lock().await;
Ok(monitor.get_status().await)
}
/// Start monitoring connectivity with adaptive polling
#[tauri::command]
pub async fn connectivity_start_monitoring(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.start_monitoring().await;
Ok(())
}
/// Stop monitoring connectivity
#[tauri::command]
pub async fn connectivity_stop_monitoring(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.stop_monitoring();
Ok(())
}
/// Mark the server as reachable (called after successful API calls)
#[tauri::command]
pub async fn connectivity_mark_reachable(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.mark_reachable().await;
Ok(())
}
/// Mark the server as unreachable (called after failed API calls)
#[tauri::command]
pub async fn connectivity_mark_unreachable(
error: Option<String>,
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.mark_unreachable(error).await;
Ok(())
}
+74
View File
@@ -0,0 +1,74 @@
//! Tauri commands for unit conversions and formatting
//!
//! These commands expose conversion utilities to the frontend,
//! allowing centralized conversion logic in Rust.
use crate::utils::conversions::{
format_time, format_time_long, calculate_progress,
ticks_to_seconds, percent_to_volume,
};
/// Format time in seconds to MM:SS display string
///
/// # Arguments
/// * `seconds` - Time in seconds
///
/// # Returns
/// Formatted string like "3:45" or "12:09"
#[tauri::command]
pub fn format_time_seconds(seconds: f64) -> String {
format_time(seconds)
}
/// Format time in seconds to HH:MM:SS or MM:SS display string
///
/// Automatically chooses format based on duration:
/// - Less than 1 hour: Returns MM:SS format
/// - 1 hour or more: Returns HH:MM:SS format
///
/// # Arguments
/// * `seconds` - Time in seconds
///
/// # Returns
/// Formatted string like "1:23:45" or "3:45"
#[tauri::command]
pub fn format_time_seconds_long(seconds: f64) -> String {
format_time_long(seconds)
}
/// Convert Jellyfin ticks to seconds
///
/// # Arguments
/// * `ticks` - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
///
/// # Returns
/// Time in seconds
#[tauri::command]
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
ticks_to_seconds(ticks)
}
/// Calculate progress percentage from position and duration
///
/// # Arguments
/// * `position` - Current position in seconds
/// * `duration` - Total duration in seconds
///
/// # Returns
/// Progress as percentage (0.0 to 100.0)
#[tauri::command]
pub fn calc_progress(position: f64, duration: f64) -> f64 {
calculate_progress(position, duration)
}
/// Convert percentage volume (0-100) to normalized (0.0-1.0)
///
/// # Arguments
/// * `percent` - Volume as percentage (0 to 100)
///
/// # Returns
/// Normalized volume (0.0 to 1.0)
#[tauri::command]
pub fn convert_percent_to_volume(percent: f64) -> f64 {
percent_to_volume(percent)
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
pub mod auth;
pub mod connectivity;
pub mod conversions;
pub mod download;
pub mod offline;
pub mod playback_mode;
pub mod playback_reporting;
pub mod player;
pub mod repository;
pub mod sessions;
pub mod storage;
pub mod sync;
pub use auth::*;
pub use connectivity::*;
pub use conversions::*;
pub use download::*;
pub use offline::*;
pub use playback_mode::*;
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
pub use playback_reporting::*;
pub use player::*;
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
pub use sessions::*;
pub use storage::*;
pub use sync::*;
+154
View File
@@ -0,0 +1,154 @@
//! Tauri commands for offline data access
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfflineItem {
pub id: String,
pub name: String,
pub item_type: String,
pub album_id: Option<String>,
pub album_name: Option<String>,
pub artists: Option<String>,
pub runtime_ticks: Option<i64>,
pub primary_image_tag: Option<String>,
}
/// Check if an item is available offline
#[tauri::command]
pub async fn offline_is_available(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<bool, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT COUNT(*) FROM downloads WHERE item_id = ? AND status = 'completed'",
vec![QueryParam::String(item_id)],
);
let count: i64 = db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
Ok(count > 0)
}
/// Get all offline items for a user
#[tauri::command]
pub async fn offline_get_items(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<Vec<OfflineItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
i.runtime_ticks, i.primary_image_tag
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.user_id = ? AND d.status = 'completed'
ORDER BY d.completed_at DESC",
vec![QueryParam::String(user_id)],
);
db_service
.query_many(query, |row| {
Ok(OfflineItem {
id: row.get(0)?,
name: row.get(1)?,
item_type: row.get(2)?,
album_id: row.get(3)?,
album_name: row.get(4)?,
artists: row.get(5)?,
runtime_ticks: row.get(6)?,
primary_image_tag: row.get(7)?,
})
})
.await
.map_err(|e| e.to_string())
}
/// Search offline items
#[tauri::command]
pub async fn offline_search(
db: State<'_, DatabaseWrapper>,
user_id: String,
query: String,
) -> Result<Vec<OfflineItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let search_query = format!("%{}%", query.to_lowercase());
let db_query = Query::with_params(
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
i.runtime_ticks, i.primary_image_tag
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.user_id = ? AND d.status = 'completed'
AND (LOWER(i.name) LIKE ? OR LOWER(i.artists) LIKE ? OR LOWER(i.album_name) LIKE ?)
ORDER BY i.name
LIMIT 50",
vec![
QueryParam::String(user_id),
QueryParam::String(search_query.clone()),
QueryParam::String(search_query.clone()),
QueryParam::String(search_query),
],
);
db_service
.query_many(db_query, |row| {
Ok(OfflineItem {
id: row.get(0)?,
name: row.get(1)?,
item_type: row.get(2)?,
album_id: row.get(3)?,
album_name: row.get(4)?,
artists: row.get(5)?,
runtime_ticks: row.get(6)?,
primary_image_tag: row.get(7)?,
})
})
.await
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offline_item_serialization() {
let item = OfflineItem {
id: "123".to_string(),
name: "Test Song".to_string(),
item_type: "Audio".to_string(),
album_id: Some("album1".to_string()),
album_name: Some("Test Album".to_string()),
artists: Some("Artist 1".to_string()),
runtime_ticks: Some(180000000),
primary_image_tag: Some("tag123".to_string()),
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"itemType\":\"Audio\""));
assert!(json.contains("\"albumName\":\"Test Album\""));
}
}
+129
View File
@@ -0,0 +1,129 @@
use std::sync::Arc;
use tauri::State;
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
/// Wrapper for PlaybackModeManager to manage in Tauri state
pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
/// Get the current playback mode
#[tauri::command]
pub fn playback_mode_get_current(
manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<PlaybackMode, String> {
Ok(manager.0.get_mode())
}
/// Set the playback mode (internal/testing use)
#[tauri::command]
pub fn playback_mode_set(
manager: State<'_, PlaybackModeManagerWrapper>,
mode: PlaybackMode,
) -> Result<(), String> {
manager.0.set_mode(mode);
Ok(())
}
/// Check if currently transferring between playback modes
#[tauri::command]
pub fn playback_mode_is_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<bool, String> {
Ok(manager.0.is_transferring())
}
/// Transfer playback from local device to a remote Jellyfin session
#[tauri::command]
pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}",
session_id
);
manager.0.transfer_to_remote(session_id).await
}
/// Transfer playback from remote session back to local device
///
/// Parameters:
/// - current_item_id: The Jellyfin item ID currently playing on remote
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
#[tauri::command]
pub async fn playback_mode_transfer_to_local(
manager: State<'_, PlaybackModeManagerWrapper>,
current_item_id: String,
position_ticks: i64,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to local: item_id={}, position={}",
current_item_id,
position_ticks
);
manager
.0
.transfer_to_local(current_item_id, position_ticks)
.await
}
/// Get remote session status (for polling position/duration)
#[tauri::command]
pub async fn playback_mode_get_remote_status(
manager: State<'_, PlaybackModeManagerWrapper>,
player: State<'_, crate::commands::PlayerStateWrapper>,
) -> Result<RemoteSessionStatus, String> {
let mode = manager.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Get Jellyfin client from player controller - clone before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
};
// Get session info
match client.get_session(&session_id).await {
Ok(Some(session)) => {
let position_ticks = session.play_state.as_ref()
.and_then(|ps| ps.position_ticks)
.unwrap_or(0);
let duration_ticks = session.now_playing_item.as_ref()
.and_then(|item| item.run_time_ticks)
.unwrap_or(0);
let is_paused = session.play_state.as_ref()
.and_then(|ps| ps.is_paused)
.unwrap_or(true);
Ok(RemoteSessionStatus {
position: position_ticks as f64 / 10_000_000.0,
duration: if duration_ticks > 0 {
Some(duration_ticks as f64 / 10_000_000.0)
} else {
None
},
is_playing: !is_paused,
now_playing_item: session.now_playing_item.clone(),
})
}
Ok(None) => Err("Remote session not found".to_string()),
Err(e) => Err(format!("Failed to get session status: {}", e)),
}
} else {
Err("Not in remote playback mode".to_string())
}
}
/// Remote session status for UI updates
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteSessionStatus {
pub position: f64,
pub duration: Option<f64>,
pub is_playing: bool,
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
}
@@ -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
}
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
// Tauri commands for repository access
// Uses handle-based system: UUID -> Arc<HybridRepository>
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use log::{debug, error, info};
use tauri::State;
use uuid::Uuid;
use crate::jellyfin::HttpClient;
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
/// Repository handle manager
pub struct RepositoryManager {
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
}
impl RepositoryManager {
pub fn new() -> Self {
Self {
repositories: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn create(&self, handle: String, repository: HybridRepository) {
let mut repos = self.repositories.lock().unwrap();
repos.insert(handle, Arc::new(repository));
}
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
let repos = self.repositories.lock().unwrap();
repos.get(handle).cloned()
}
pub fn destroy(&self, handle: &str) {
let mut repos = self.repositories.lock().unwrap();
repos.remove(handle);
}
}
/// Wrapper for Tauri state
pub struct RepositoryManagerWrapper(pub RepositoryManager);
/// Create a new repository instance
/// Returns a handle (UUID) for accessing the repository
#[tauri::command]
pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>,
server_url: String,
user_id: String,
access_token: String,
server_id: String,
) -> Result<String, String> {
info!("[REPO] repository_create called for user: {}", user_id);
// Create HTTP client for online repository
debug!("[REPO] Creating HTTP client...");
let http_config = crate::jellyfin::HttpConfig::default();
let http_client = HttpClient::new(http_config).map_err(|e| {
error!("[REPO] HTTP client creation failed: {}", e);
e.to_string()
})?;
debug!("[REPO] HTTP client created successfully");
// Create online repository
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token);
debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service
debug!("[REPO] Creating database service...");
let db_service = {
let database = db.0.lock().map_err(|e| {
error!("[REPO] Database lock failed: {}", e);
e.to_string()
})?;
debug!("[REPO] Database lock acquired, getting service...");
Arc::new(database.service())
}; // Lock is released here
debug!("[REPO] Database service created");
debug!("[REPO] Creating offline repository...");
let offline = OfflineRepository::new(db_service, server_id, user_id);
debug!("[REPO] Offline repository created");
// Create hybrid repository
debug!("[REPO] Creating hybrid repository...");
let hybrid = HybridRepository::new(online, offline);
debug!("[REPO] Hybrid repository created");
// Generate handle and store repository
let uuid = Uuid::new_v4();
let handle = format!("{}", uuid);
info!("[REPO] Generated handle: {}", handle);
// Store repository synchronously
debug!("[REPO] Storing repository...");
manager.0.create(handle.clone(), hybrid);
info!("[REPO] Repository stored successfully");
Ok(handle)
}
/// Destroy a repository instance
#[tauri::command]
pub async fn repository_destroy(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<(), String> {
manager.0.destroy(&handle);
Ok(())
}
/// Get libraries
#[tauri::command]
pub async fn repository_get_libraries(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<Library>, String> {
debug!("[REPO] get_libraries called with handle: {}", handle);
let repo = manager.0.get(&handle).ok_or_else(|| {
error!("[REPO] Repository not found for handle: {}", handle);
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching libraries...");
repo.as_ref().get_libraries()
.await
.map_err(|e| {
error!("[REPO] Error fetching libraries: {:?}", e);
format!("{:?}", e)
})
}
/// Get items in a container (library, folder, album, etc.)
#[tauri::command]
pub async fn repository_get_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: String,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items(&parent_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get a single item by ID
#[tauri::command]
pub async fn repository_get_item(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_item(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get latest items in a library
#[tauri::command]
pub async fn repository_get_latest_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: String,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_latest_items(&parent_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get resume items (continue watching/listening)
#[tauri::command]
pub async fn repository_get_resume_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
debug!("[REPO] get_resume_items called with handle: {}", handle);
let repo = manager.0.get(&handle).ok_or_else(|| {
error!("[REPO] Repository not found for handle: {}", handle);
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching resume items...");
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
.await
.map_err(|e| {
error!("[REPO] Error fetching resume items: {:?}", e);
format!("{:?}", e)
})
}
/// Get next up episodes
#[tauri::command]
pub async fn repository_get_next_up_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: Option<String>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_next_up_episodes(series_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get recently played audio
#[tauri::command]
pub async fn repository_get_recently_played_audio(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_recently_played_audio(limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get resume movies
#[tauri::command]
pub async fn repository_get_resume_movies(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_resume_movies(limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get genres for a library
#[tauri::command]
pub async fn repository_get_genres(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
) -> Result<Vec<Genre>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_genres(parent_id.as_deref())
.await
.map_err(|e| format!("{:?}", e))
}
/// Search for items
#[tauri::command]
pub async fn repository_search(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
query: String,
options: Option<SearchOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().search(&query, options)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get playback info for an item
#[tauri::command]
pub async fn repository_get_playback_info(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<PlaybackInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_playback_info(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get video stream URL with optional seeking support
#[tauri::command]
pub async fn repository_get_video_stream_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
media_source_id: Option<String>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_video_stream_url(
&item_id,
media_source_id.as_deref(),
start_time_seconds,
audio_stream_index,
)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get audio stream URL for a track
#[tauri::command]
pub async fn repository_get_audio_stream_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_audio_stream_url(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback start
#[tauri::command]
pub async fn repository_report_playback_start(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_start(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback progress
#[tauri::command]
pub async fn repository_report_playback_progress(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_progress(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback stopped
#[tauri::command]
pub async fn repository_report_playback_stopped(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_stopped(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get image URL for an item
#[tauri::command]
pub fn repository_get_image_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
image_type: ImageType,
options: Option<ImageOptions>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
}
/// Mark an item as favorite
#[tauri::command]
pub async fn repository_mark_favorite(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().mark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Unmark an item as favorite
#[tauri::command]
pub async fn repository_unmark_favorite(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().unmark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get person details
#[tauri::command]
pub async fn repository_get_person(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
person_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_person(&person_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get items by person (actor, director, etc.)
#[tauri::command]
pub async fn repository_get_items_by_person(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
person_id: String,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items_by_person(&person_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get similar/related items for a media item
#[tauri::command]
pub async fn repository_get_similar_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
limit: Option<usize>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_similar_items(&item_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
+32
View File
@@ -0,0 +1,32 @@
use std::sync::Arc;
use tauri::State;
use crate::session_poller::{PollingHint, SessionPollerManager};
use crate::jellyfin::client::SessionInfo;
/// Tauri state wrapper for SessionPollerManager
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
/// Set polling frequency hint based on UI state
#[tauri::command]
pub fn sessions_set_polling_hint(
poller: State<'_, SessionPollerWrapper>,
hint: String,
) -> Result<(), String> {
let parsed_hint = match hint.as_str() {
"cast_active" => PollingHint::CastActive,
"cast_discovery" => PollingHint::CastDiscovery,
"normal" => PollingHint::Normal,
_ => return Err(format!("Invalid polling hint: {}", hint)),
};
poller.0.set_polling_hint(parsed_hint);
Ok(())
}
/// Manually trigger a session poll (for refresh button)
#[tauri::command]
pub async fn sessions_poll_now(
poller: State<'_, SessionPollerWrapper>,
) -> Result<Vec<SessionInfo>, String> {
poller.0.poll_now().await
}
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
//! Tauri commands for sync queue operations
//!
//! The sync queue stores mutations (favorites, playback progress, etc.)
//! that need to be synced to the Jellyfin server when connectivity is restored.
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::storage::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Sync queue item returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncQueueItem {
pub id: i64,
pub user_id: String,
pub operation: String,
pub item_id: Option<String>,
pub payload: Option<String>,
pub status: String,
pub retry_count: i32,
pub created_at: Option<String>,
pub error_message: Option<String>,
}
/// Queue a mutation for sync to server
#[tauri::command]
pub async fn sync_queue_mutation(
db: State<'_, DatabaseWrapper>,
user_id: String,
operation: String,
item_id: Option<String>,
payload: Option<String>,
) -> Result<i64, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
vec![
QueryParam::String(user_id),
QueryParam::String(operation),
item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
let id = db_service.last_insert_rowid().await.map_err(|e| e.to_string())?;
Ok(id)
}
/// Get all pending sync operations for a user
#[tauri::command]
pub async fn sync_get_pending(
db: State<'_, DatabaseWrapper>,
user_id: String,
limit: Option<i32>,
) -> Result<Vec<SyncQueueItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let sql = if let Some(l) = limit {
format!(
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
FROM sync_queue
WHERE user_id = ? AND status IN ('pending', 'failed')
ORDER BY created_at ASC
LIMIT {}",
l
)
} else {
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
FROM sync_queue
WHERE user_id = ? AND status IN ('pending', 'failed')
ORDER BY created_at ASC".to_string()
};
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
db_service
.query_many(query, |row| {
Ok(SyncQueueItem {
id: row.get(0)?,
user_id: row.get(1)?,
operation: row.get(2)?,
item_id: row.get(3)?,
payload: row.get(4)?,
status: row.get(5)?,
retry_count: row.get(6)?,
created_at: row.get(7)?,
error_message: row.get(8)?,
})
})
.await
.map_err(|e| e.to_string())
}
/// Mark a sync operation as in progress
#[tauri::command]
pub async fn sync_mark_processing(
db: State<'_, DatabaseWrapper>,
id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue SET status = 'processing' WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Mark a sync operation as completed
#[tauri::command]
pub async fn sync_mark_completed(
db: State<'_, DatabaseWrapper>,
id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Mark a sync operation as failed with error message
#[tauri::command]
pub async fn sync_mark_failed(
db: State<'_, DatabaseWrapper>,
id: i64,
error: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = ?,
processed_at = CURRENT_TIMESTAMP
WHERE id = ?",
vec![QueryParam::String(error), QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Get count of pending sync operations for a user
#[tauri::command]
pub async fn sync_get_pending_count(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<i32, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
vec![QueryParam::String(user_id)],
);
db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())
}
/// Delete completed sync operations older than specified days
#[tauri::command]
pub async fn sync_cleanup_completed(
db: State<'_, DatabaseWrapper>,
days_old: i32,
) -> Result<i32, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"DELETE FROM sync_queue
WHERE status = 'completed'
AND processed_at < datetime('now', ?)",
vec![QueryParam::String(format!("-{} days", days_old))],
);
let deleted = db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(deleted as i32)
}
/// Delete all sync operations for a user (used during logout)
#[tauri::command]
pub async fn sync_clear_user(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"DELETE FROM sync_queue WHERE user_id = ?",
vec![QueryParam::String(user_id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
+370
View File
@@ -0,0 +1,370 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tauri::{AppHandle, Emitter};
use serde::{Serialize, Deserialize};
use crate::jellyfin::http_client::HttpClient;
// Adaptive polling intervals (matches TypeScript)
const AUTO_CHECK_INTERVAL_MS: u64 = 30000; // 30 seconds when online
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
/// Connectivity status
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectivityStatus {
/// Whether the Jellyfin server is reachable
pub is_server_reachable: bool,
/// Last time we checked server reachability (ISO 8601 string)
pub last_checked: Option<String>,
/// Error message from last connectivity check
pub connection_error: Option<String>,
/// Whether we're currently checking connectivity
pub is_checking: bool,
}
impl Default for ConnectivityStatus {
fn default() -> Self {
Self {
// Start optimistic - assume online until proven otherwise
// This prevents the app from appearing offline on startup
is_server_reachable: true,
last_checked: None,
connection_error: None,
is_checking: false,
}
}
}
/// Connectivity change event emitted to frontend
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ConnectivityChangeEvent {
is_reachable: bool,
}
/// Connectivity monitor for tracking server reachability
pub struct ConnectivityMonitor {
server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>,
status: Arc<RwLock<ConnectivityStatus>>,
is_monitoring: Arc<AtomicBool>,
app_handle: Option<AppHandle>,
}
impl ConnectivityMonitor {
/// Create a new connectivity monitor
pub fn new(http_client: HttpClient) -> Self {
Self {
server_url: Arc::new(RwLock::new(None)),
http_client: Arc::new(http_client),
status: Arc::new(RwLock::new(ConnectivityStatus::default())),
is_monitoring: Arc::new(AtomicBool::new(false)),
app_handle: None,
}
}
/// Set the Tauri app handle for event emission
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.app_handle = Some(app_handle);
}
/// Update the server URL
pub async fn set_server_url(&self, url: String) {
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
let mut server_url = self.server_url.write().await;
*server_url = Some(url.clone());
drop(server_url);
// Check new server immediately
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
}
/// Get current connectivity status
pub async fn get_status(&self) -> ConnectivityStatus {
self.status.read().await.clone()
}
/// Check if the Jellyfin server is reachable
pub async fn check_reachability(&self) -> bool {
// Mark as checking
{
let mut status = self.status.write().await;
status.is_checking = true;
}
let server_url = self.server_url.read().await.clone();
if server_url.is_none() {
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
let mut status = self.status.write().await;
status.is_server_reachable = false;
status.connection_error = Some("No server URL configured".to_string());
status.is_checking = false;
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
};
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
// Attempt to ping the server
let is_reachable = self.http_client.ping(&ping_url).await;
log::debug!(
"[ConnectivityMonitor] Ping result: {} (was: {})",
if is_reachable { "SUCCESS" } else { "FAILED" },
if was_reachable { "reachable" } else { "unreachable" }
);
// Update status
{
let mut status = self.status.write().await;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
};
status.is_checking = false;
}
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
is_reachable
}
/// Mark server as reachable (called after successful API call)
pub async fn mark_reachable(&self) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = true;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = None;
drop(status);
if !was_reachable {
log::info!("[ConnectivityMonitor] Server marked as reachable (was unreachable)");
self.emit_connectivity_change(true).await;
self.emit_server_reconnected().await;
}
}
/// Mark server as unreachable (called after failed API call)
pub async fn mark_unreachable(&self, error: Option<String>) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = false;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = error.or_else(|| Some("Server unreachable".to_string()));
let error_msg = status.connection_error.clone().unwrap_or_default();
drop(status);
if was_reachable {
log::warn!("[ConnectivityMonitor] Server marked as unreachable (was reachable): {}", error_msg);
self.emit_connectivity_change(false).await;
}
}
/// Start monitoring connectivity with adaptive polling
pub async fn start_monitoring(&self) {
if self.is_monitoring.swap(true, Ordering::SeqCst) {
log::info!("[ConnectivityMonitor] Already monitoring");
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring");
// Perform immediate check before starting background task
// This ensures we get an accurate state right away instead of assuming offline
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
// Clone Arc references for the background task
let status = Arc::clone(&self.status);
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
let http_client = Arc::clone(&self.http_client);
let self_clone = Arc::new(ConnectivityMonitorHandle {
server_url,
http_client,
status,
app_handle: self.app_handle.clone(),
});
// Spawn background monitoring task
tokio::spawn(async move {
while is_monitoring.load(Ordering::SeqCst) {
// Determine interval based on current reachability
let interval_ms = {
let status = self_clone.status.read().await;
if status.is_server_reachable {
AUTO_CHECK_INTERVAL_MS
} else {
RETRY_CHECK_INTERVAL_MS
}
};
// Wait for the interval
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
// Check if still monitoring
if !is_monitoring.load(Ordering::SeqCst) {
break;
}
// Perform connectivity check
let _ = self_clone.check_reachability().await;
}
log::info!("[ConnectivityMonitor] Stopped monitoring");
});
}
/// Stop monitoring connectivity
pub fn stop_monitoring(&self) {
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
self.is_monitoring.store(false, Ordering::SeqCst);
}
/// Emit connectivity change event to frontend
async fn emit_connectivity_change(&self, is_reachable: bool) {
if let Some(app_handle) = &self.app_handle {
let event = ConnectivityChangeEvent { is_reachable };
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
} else {
log::info!("[ConnectivityMonitor] Emitted connectivity change: {}", is_reachable);
}
}
}
/// Emit server reconnected event to frontend
async fn emit_server_reconnected(&self) {
if let Some(app_handle) = &self.app_handle {
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
} else {
log::info!("[ConnectivityMonitor] Emitted server reconnected event");
}
}
}
}
/// Handle for the background monitoring task
struct ConnectivityMonitorHandle {
server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>,
status: Arc<RwLock<ConnectivityStatus>>,
app_handle: Option<AppHandle>,
}
impl ConnectivityMonitorHandle {
async fn check_reachability(&self) -> bool {
let server_url = self.server_url.read().await.clone();
if server_url.is_none() {
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
};
// Attempt to ping the server
let is_reachable = self.http_client.ping(&ping_url).await;
// Update status
{
let mut status = self.status.write().await;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
};
}
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
is_reachable
}
async fn emit_connectivity_change(&self, is_reachable: bool) {
if let Some(app_handle) = &self.app_handle {
let event = ConnectivityChangeEvent { is_reachable };
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
}
}
}
async fn emit_server_reconnected(&self) {
if let Some(app_handle) = &self.app_handle {
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jellyfin::http_client::HttpConfig;
#[test]
fn test_intervals() {
// Verify intervals match TypeScript
assert_eq!(AUTO_CHECK_INTERVAL_MS, 30000);
assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
}
#[tokio::test]
async fn test_default_status() {
let status = ConnectivityStatus::default();
// Default is now optimistic (assume online until proven otherwise)
assert!(status.is_server_reachable);
assert!(status.last_checked.is_none());
assert!(status.connection_error.is_none());
assert!(!status.is_checking);
}
}
+820
View File
@@ -0,0 +1,820 @@
//! Secure credential storage module
//!
//! Provides secure storage for access tokens using:
//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
//!
//! The fallback is less secure as the encryption key is derived from machine
//! identifiers, but provides functionality on headless systems.
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use directories::ProjectDirs;
use log::{info, warn};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
#[cfg(target_os = "linux")]
use hostname;
#[cfg(not(target_os = "android"))]
const SERVICE_NAME: &str = "com.dtourolle.jellytau";
const CREDENTIALS_FILENAME: &str = "credentials.enc";
/// Result of a credential storage operation
#[derive(Debug)]
pub enum CredentialResult {
/// Operation succeeded using the system keyring
Keyring,
/// Operation succeeded using encrypted file fallback
EncryptedFile,
}
/// Error types for credential operations
#[derive(Debug)]
pub enum CredentialError {
/// Keyring operation failed
Keyring(String),
/// Encryption/decryption failed
Encryption(String),
/// File I/O failed
Io(String),
/// Credential not found
NotFound,
}
impl std::fmt::Display for CredentialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Keyring(msg) => write!(f, "Keyring error: {}", msg),
Self::Encryption(msg) => write!(f, "Encryption error: {}", msg),
Self::Io(msg) => write!(f, "I/O error: {}", msg),
Self::NotFound => write!(f, "Credential not found"),
}
}
}
impl std::error::Error for CredentialError {}
/// Credential storage manager
pub struct CredentialStore {
/// Whether we're using keyring (true) or encrypted file (false)
using_keyring: bool,
/// Path to the encrypted credentials file (fallback)
credentials_path: PathBuf,
/// Encryption key for file fallback (derived from machine ID)
encryption_key: [u8; 32],
}
impl CredentialStore {
/// Create a new credential store, detecting the best available backend
pub fn new() -> Self {
let credentials_path = Self::get_credentials_path();
let encryption_key = Self::derive_encryption_key();
// Test if keyring is available by trying a dummy operation
let using_keyring = Self::test_keyring_available();
if !using_keyring {
warn!(
"[INIT] System keyring unavailable, using encrypted file fallback at {:?}. \
This is less secure than system keyring storage.",
credentials_path
);
} else {
info!("[INIT] Using system keyring for credential storage");
}
Self {
using_keyring,
credentials_path,
encryption_key,
}
}
/// Check if we're using the secure keyring backend
pub fn is_using_keyring(&self) -> bool {
self.using_keyring
}
/// Save an access token for a user
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
if self.using_keyring {
log::debug!("Saving token for user {} to keyring", user_id);
self.save_to_keyring(user_id, token)?;
Ok(CredentialResult::Keyring)
} else {
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
self.save_to_file(user_id, token)?;
log::debug!("Successfully saved token to encrypted file");
Ok(CredentialResult::EncryptedFile)
}
}
/// Get an access token for a user
pub fn get_token(&self, user_id: &str) -> Result<String, CredentialError> {
if self.using_keyring {
log::debug!("Getting token for user {} from keyring", user_id);
self.get_from_keyring(user_id)
} else {
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
let result = self.get_from_file(user_id);
if result.is_ok() {
log::debug!("Successfully retrieved token from encrypted file");
} else {
log::warn!("Failed to retrieve token from encrypted file: {:?}", result);
}
result
}
}
/// Delete an access token for a user
pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError> {
if self.using_keyring {
self.delete_from_keyring(user_id)
} else {
self.delete_from_file(user_id)
}
}
// --- Keyring backend ---
fn test_keyring_available() -> bool {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_test_keystore_available()
}
// On Linux, the keyring test can block indefinitely if Secret Service
// (gnome-keyring/kwallet) is unresponsive. Use a timeout to prevent hanging.
#[cfg(target_os = "linux")]
{
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = Self::test_keyring_inner();
let _ = tx.send(result);
});
// Wait up to 2 seconds for keyring response
match rx.recv_timeout(Duration::from_secs(2)) {
Ok(result) => result,
Err(_) => {
log::warn!("Keyring availability check timed out after 2 seconds");
false
}
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
Self::test_keyring_inner()
}
}
#[cfg(target_os = "linux")]
fn test_keyring_inner() -> bool {
// On Linux, test if secret-tool is available
use std::process::Command;
// secret-tool doesn't support --version, so we test with a search command
// that will succeed even if no items are found
match Command::new("secret-tool")
.arg("search")
.arg("service")
.arg("__nonexistent_test__")
.output()
{
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Err(_) => false, // Command not found or can't execute
}
}
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
fn test_keyring_inner() -> bool {
// On macOS/Windows, test using the keyring-rs library
let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
match entry {
Ok(e) => {
// Try to get (will fail with NotFound, which is fine)
// If it fails with a different error, keyring is not available
match e.get_password() {
Ok(_) => true,
Err(keyring::Error::NoEntry) => true,
Err(keyring::Error::NoStorageAccess(_)) => false,
Err(_) => false,
}
}
Err(_) => false,
}
}
fn save_to_keyring(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::save_token(user_id, token)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::{Command, Stdio};
use std::io::Write;
let key = format!("access_token:{}", user_id);
let mut child = Command::new("secret-tool")
.arg("store")
.arg("--label")
.arg(format!("{}@{}", key, SERVICE_NAME))
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(token.as_bytes())
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
}
let status = child.wait()
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
entry
.set_password(token)
.map_err(|e| CredentialError::Keyring(e.to_string()))
}
}
fn get_from_keyring(&self, user_id: &str) -> Result<String, CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::get_token(user_id)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::Command;
let key = format!("access_token:{}", user_id);
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
let output = Command::new("secret-tool")
.arg("lookup")
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.output()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
if output.status.success() {
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
let token = String::from_utf8(output.stdout)
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
.trim()
.to_string();
Ok(token)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
Err(CredentialError::NotFound)
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
entry.get_password().map_err(|e| match e {
keyring::Error::NoEntry => CredentialError::NotFound,
_ => CredentialError::Keyring(e.to_string()),
})
}
}
fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::delete_token(user_id)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::Command;
let key = format!("access_token:{}", user_id);
let status = Command::new("secret-tool")
.arg("clear")
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.status()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
// secret-tool clear returns success even if entry doesn't exist
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
match entry.delete_credential() {
Ok(_) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
Err(e) => Err(CredentialError::Keyring(e.to_string())),
}
}
}
// --- Encrypted file backend ---
fn get_credentials_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
} else {
PathBuf::from(CREDENTIALS_FILENAME)
}
}
fn derive_encryption_key() -> [u8; 32] {
// Derive a key from machine-specific identifiers
// This is less secure than a true keyring but provides some protection
let mut hasher = Sha256::new();
// Use hostname on Linux (where it's available and stable)
#[cfg(target_os = "linux")]
{
if let Ok(hostname) = hostname::get() {
hasher.update(hostname.to_string_lossy().as_bytes());
}
}
// On Android, read device properties from the filesystem
#[cfg(target_os = "android")]
{
// Try to read Android build properties from /system/build.prop
let build_prop_paths = [
"/system/build.prop",
"/vendor/build.prop",
];
for path in &build_prop_paths {
if let Ok(content) = fs::read_to_string(path) {
// Extract key properties for device fingerprint
for line in content.lines() {
if line.starts_with("ro.build.fingerprint=")
|| line.starts_with("ro.serialno=")
|| line.starts_with("ro.build.id=")
|| line.starts_with("ro.product.model=") {
hasher.update(line.as_bytes());
}
}
}
}
// Also use the app data directory path as it's device/install-specific
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
hasher.update(proj_dirs.data_dir().to_string_lossy().as_bytes());
}
}
// Use a static salt (app-specific)
hasher.update(b"jellytau-credential-encryption-v1");
// Add username for additional entropy (if available)
if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
hasher.update(user.as_bytes());
}
hasher.finalize().into()
}
fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
if !self.credentials_path.exists() {
return Ok(serde_json::json!({}));
}
let encrypted_data =
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
if encrypted_data.is_empty() {
return Ok(serde_json::json!({}));
}
let decrypted = self.decrypt(&encrypted_data)?;
serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string()))
}
fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
// Ensure parent directory exists
if let Some(parent) = self.credentials_path.parent() {
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
}
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let encrypted = self.encrypt(&json)?;
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
}
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Generate a random nonce
let mut nonce_bytes = [0u8; 12];
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Prepend nonce to ciphertext and encode as base64
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
Ok(BASE64.encode(&combined))
}
fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
let combined = BASE64
.decode(encrypted)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
if combined.len() < 12 {
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
}
fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
let mut data = self.load_credentials_file()?;
data[user_id] = serde_json::json!(token);
self.save_credentials_file(&data)
}
fn get_from_file(&self, user_id: &str) -> Result<String, CredentialError> {
let data = self.load_credentials_file()?;
data.get(user_id)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or(CredentialError::NotFound)
}
fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError> {
let mut data = self.load_credentials_file()?;
if let Some(obj) = data.as_object_mut() {
obj.remove(user_id);
}
self.save_credentials_file(&data)
}
}
impl Default for CredentialStore {
fn default() -> Self {
Self::new()
}
}
// --- Android Keystore integration via JNI ---
#[cfg(target_os = "android")]
mod android_keystore {
use super::*;
use jni::objects::{JClass, JObject, JString, JValue};
use jni::JNIEnv;
use std::sync::OnceLock;
/// Cached reference to the SecureStorage class
static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
/// Initialize the SecureStorage singleton from Android context
pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
log::info!("Initializing Android SecureStorage...");
// Get the ClassLoader from the Context
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the SecureStorage class
let class_name = env
.new_string(SECURE_STORAGE_CLASS_NAME.replace('/', "."))
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let storage_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name.into())],
)
.map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let storage_class = JClass::from(storage_class_obj);
// Call SecureStorage.initialize(context)
env.call_static_method(
&storage_class,
"initialize",
"(Landroid/content/Context;)V",
&[JValue::Object(context)],
)
.map_err(|e| format!("Failed to initialize SecureStorage: {}", e))?;
// Cache the class name for future use
let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
log::info!("Android SecureStorage initialized successfully");
Ok(())
}
/// Test if Android Keystore is available
pub fn test_keystore_available() -> bool {
// Get JNI environment
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
let vm = match vm {
Ok(vm) => vm,
Err(e) => {
log::warn!("Failed to get JavaVM for keystore test: {}", e);
return false;
}
};
let mut env = match vm.attach_current_thread() {
Ok(env) => env,
Err(e) => {
log::warn!("Failed to attach thread for keystore test: {}", e);
return false;
}
};
// Try to get the SecureStorage instance
match get_secure_storage_instance(&mut env) {
Ok(_) => {
log::info!("Android Keystore available via SecureStorage");
true
}
Err(e) => {
log::warn!("Android Keystore not available: {}", e);
false
}
}
}
/// Get the SecureStorage singleton instance
fn get_secure_storage_instance<'a>(env: &mut JNIEnv<'a>) -> Result<JObject<'a>, String> {
let class_name = SECURE_STORAGE_CLASS
.get()
.ok_or_else(|| "SecureStorage not initialized".to_string())?;
// Get the Android context
let ctx = ndk_context::android_context();
let context = unsafe { JObject::from_raw(ctx.context().cast()) };
// Get the ClassLoader from the Context
let class_loader = env
.call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the SecureStorage class using the app's classloader
let class_name_jstring = env
.new_string(class_name.replace('/', "."))
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let storage_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name_jstring.into())],
)
.map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let storage_class = JClass::from(storage_class_obj);
let instance = env
.call_static_method(
&storage_class,
"getInstance",
"()Lcom/dtourolle/jellytau/security/SecureStorage;",
&[],
)
.map_err(|e| format!("Failed to get SecureStorage instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
Ok(instance)
}
/// Save a token using Android Keystore
pub fn save_token(user_id: &str, token: &str) -> Result<(), CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let token_jstring = env
.new_string(token)
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
let result = env
.call_method(
instance,
"saveToken",
"(Ljava/lang/String;Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("saveToken returned false".to_string()))
}
}
/// Get a token from Android Keystore
pub fn get_token(user_id: &str) -> Result<String, CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let result = env
.call_method(
instance,
"getToken",
"(Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(&key_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call getToken: {}", e)))?
.l()
.map_err(|e| CredentialError::Keyring(format!("Failed to get object result: {}", e)))?;
if result.is_null() {
return Err(CredentialError::NotFound);
}
let token_jstring = JString::from(result);
let token: String = env
.get_string(&token_jstring)
.map_err(|e| CredentialError::Keyring(format!("Failed to get string: {}", e)))?
.into();
Ok(token)
}
/// Delete a token from Android Keystore
pub fn delete_token(user_id: &str) -> Result<(), CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let result = env
.call_method(
instance,
"deleteToken",
"(Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
}
}
}
// Export Android keystore functions at the module level for easier access
#[cfg(target_os = "android")]
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encryption_roundtrip() {
let store = CredentialStore::new();
let plaintext = "test-access-token-12345";
let encrypted = store.encrypt(plaintext).unwrap();
let decrypted = store.decrypt(&encrypted).unwrap();
assert_eq!(plaintext, decrypted);
}
#[test]
fn test_derive_encryption_key_is_deterministic() {
let key1 = CredentialStore::derive_encryption_key();
let key2 = CredentialStore::derive_encryption_key();
assert_eq!(key1, key2);
}
}
+326
View File
@@ -0,0 +1,326 @@
//! Smart caching engine for predictive downloads
use log::{debug, info};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Smart caching configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheConfig {
/// Enable queue pre-caching
pub queue_precache_enabled: bool,
/// Number of tracks to pre-cache from queue
pub queue_precache_count: usize,
/// Enable album affinity detection
pub album_affinity_enabled: bool,
/// Threshold for album affinity (tracks played before caching)
pub album_affinity_threshold: usize,
/// Storage limit in bytes (0 = unlimited)
pub storage_limit: u64,
/// Only cache on WiFi
pub wifi_only: bool,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
queue_precache_enabled: true,
queue_precache_count: 3, // Preload next 3 tracks by default
album_affinity_enabled: true,
album_affinity_threshold: 3,
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
wifi_only: false, // Allow preloading on any connection by default
}
}
}
/// Smart caching engine
#[derive(Clone)]
pub struct SmartCache {
config: Arc<Mutex<CacheConfig>>,
/// Track recently played items per album
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
}
impl SmartCache {
pub fn new(config: CacheConfig) -> Self {
Self {
config: Arc::new(Mutex::new(config)),
album_play_history: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Update configuration
pub fn update_config(&self, config: CacheConfig) {
if let Ok(mut cfg) = self.config.lock() {
*cfg = config;
}
}
/// Check if should pre-cache queue items
pub fn should_precache_queue(&self) -> bool {
self.config
.lock()
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
.unwrap_or(false)
}
/// Get number of queue items to pre-cache
pub fn queue_precache_count(&self) -> usize {
self.config
.lock()
.map(|cfg| cfg.queue_precache_count)
.unwrap_or(5)
}
/// Track that an item was played
pub fn track_play(&self, item_id: &str, album_id: Option<&str>) {
if let Some(album) = album_id {
if let Ok(mut history) = self.album_play_history.lock() {
let plays = history.entry(album.to_string()).or_insert_with(Vec::new);
if !plays.contains(&item_id.to_string()) {
plays.push(item_id.to_string());
}
}
}
}
/// Check if album affinity threshold reached for caching
pub fn should_cache_album(&self, album_id: &str) -> Option<bool> {
let config = self.config.lock().ok()?;
if !config.album_affinity_enabled {
return Some(false);
}
let history = self.album_play_history.lock().ok()?;
let play_count = history.get(album_id).map(|v| v.len()).unwrap_or(0);
Some(play_count >= config.album_affinity_threshold)
}
/// Get configuration
pub fn get_config(&self) -> Option<CacheConfig> {
self.config.lock().ok().map(|cfg| cfg.clone())
}
/// Get all tracked albums with their play counts
/// Returns Vec<(album_id, unique_tracks_played)>
pub fn get_album_play_history(&self) -> Vec<(String, usize)> {
self.album_play_history
.lock()
.ok()
.map(|history| {
history
.iter()
.map(|(album_id, tracks)| (album_id.clone(), tracks.len()))
.collect()
})
.unwrap_or_default()
}
// ============= Async versions for DatabaseService =============
/// Get total download size for a user (async version)
pub async fn get_total_download_size_async<S: DatabaseService>(
&self,
db_service: &Arc<S>,
user_id: &str,
) -> Result<u64, String> {
let query = Query::with_params(
"SELECT COALESCE(SUM(file_size), 0) FROM downloads
WHERE user_id = ? AND status = 'completed'",
vec![QueryParam::String(user_id.to_string())],
);
let size: i64 = db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
Ok(size as u64)
}
/// Check if storage limit allows download (async version)
pub async fn can_download_async<S: DatabaseService>(
&self,
db_service: &Arc<S>,
user_id: &str,
new_size: u64,
) -> bool {
// Clone config to avoid holding lock across await
let storage_limit = {
match self.config.lock() {
Ok(cfg) => cfg.storage_limit,
Err(_) => return true,
}
};
if storage_limit == 0 {
return true; // Unlimited
}
let current_size = self
.get_total_download_size_async(db_service, user_id)
.await
.unwrap_or(0);
current_size + new_size <= storage_limit
}
/// Evict least recently used items to make space (async version)
pub async fn evict_lru_async<S: DatabaseService>(
&self,
db_service: &Arc<S>,
user_id: &str,
space_needed: u64,
) -> Result<u64, String> {
let current_size = self
.get_total_download_size_async(db_service, user_id)
.await?;
// Get limit without holding lock across await
let limit = {
let config = self.config.lock().map_err(|e| e.to_string())?;
config.storage_limit
};
if limit == 0 || current_size + space_needed <= limit {
return Ok(0); // No eviction needed
}
let to_free = (current_size + space_needed) - limit;
let mut freed: u64 = 0;
// Get downloads ordered by last access (oldest first)
let query = Query::with_params(
"SELECT id, file_size, file_path FROM downloads
WHERE user_id = ? AND status = 'completed'
ORDER BY completed_at ASC",
vec![QueryParam::String(user_id.to_string())],
);
let downloads: Vec<(i64, i64, String)> = db_service
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.map_err(|e| e.to_string())?;
for (id, size, file_path) in downloads {
if freed >= to_free {
break;
}
// Delete file
let _ = std::fs::remove_file(&file_path);
debug!("[SmartCache] Evicted: {} ({} bytes)", file_path, size);
// Delete from database
let delete_query = Query::with_params(
"DELETE FROM downloads WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
freed += size as u64;
}
info!("[SmartCache] Freed {} bytes ({} needed)", freed, to_free);
Ok(freed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = CacheConfig::default();
assert_eq!(config.queue_precache_count, 3);
assert_eq!(config.album_affinity_threshold, 3);
assert!(!config.wifi_only); // wifi_only is false by default for easier preloading
}
#[test]
fn test_album_affinity_tracking() {
let cache = SmartCache::new(CacheConfig::default());
// Track plays from same album
cache.track_play("track1", Some("album1"));
cache.track_play("track2", Some("album1"));
// Below threshold
assert!(!cache.should_cache_album("album1").unwrap_or(false));
cache.track_play("track3", Some("album1"));
// At threshold - should cache
assert!(cache.should_cache_album("album1").unwrap_or(false));
}
#[test]
fn test_queue_precache_config() {
let mut config = CacheConfig::default();
config.queue_precache_enabled = false;
let cache = SmartCache::new(config);
assert!(!cache.should_precache_queue());
let mut new_config = CacheConfig::default();
new_config.wifi_only = false;
cache.update_config(new_config);
assert!(cache.should_precache_queue());
}
#[tokio::test]
async fn test_storage_limit_check() {
use crate::storage::db_service::RusqliteService;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE downloads (
id INTEGER PRIMARY KEY,
user_id TEXT,
status TEXT,
file_size INTEGER
)",
[],
)
.unwrap();
let conn_arc = Arc::new(Mutex::new(conn));
let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
let config = CacheConfig {
storage_limit: 1000,
..Default::default()
};
let cache = SmartCache::new(config);
// Empty - can download
assert!(cache.can_download_async(&db_service, "user1", 500).await);
// Add some downloads
{
let conn_guard = conn_arc.lock().unwrap();
conn_guard.execute(
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
[],
)
.unwrap();
}
// Total would be 1100 > 1000
assert!(!cache.can_download_async(&db_service, "user1", 500).await);
// Smaller size fits
assert!(cache.can_download_async(&db_service, "user1", 300).await);
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Download events for progress tracking and status updates
use serde::{Deserialize, Serialize};
/// Events emitted during download operations
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum DownloadEvent {
/// Download has been queued
#[serde(rename_all = "camelCase")]
Queued {
download_id: i64,
item_id: String,
},
/// Download has started
#[serde(rename_all = "camelCase")]
Started {
download_id: i64,
item_id: String,
},
/// Download progress update
#[serde(rename_all = "camelCase")]
Progress {
download_id: i64,
item_id: String,
bytes_downloaded: i64,
total_bytes: Option<i64>,
progress: f64, // 0.0 to 1.0
},
/// Download completed successfully
#[serde(rename_all = "camelCase")]
Completed {
download_id: i64,
item_id: String,
file_path: String,
},
/// Download failed with error
#[serde(rename_all = "camelCase")]
Failed {
download_id: i64,
item_id: String,
error: String,
},
/// Download paused
#[serde(rename_all = "camelCase")]
Paused {
download_id: i64,
item_id: String,
},
/// Download cancelled
#[serde(rename_all = "camelCase")]
Cancelled {
download_id: i64,
item_id: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_download_event_serialization_roundtrip() {
let event = DownloadEvent::Progress {
download_id: 1,
item_id: "test123".to_string(),
bytes_downloaded: 1024,
total_bytes: Some(2048),
progress: 0.5,
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
match deserialized {
DownloadEvent::Progress {
download_id,
item_id,
progress,
..
} => {
assert_eq!(download_id, 1);
assert_eq!(item_id, "test123");
assert_eq!(progress, 0.5);
}
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_download_event_completed() {
let event = DownloadEvent::Completed {
download_id: 42,
item_id: "song456".to_string(),
file_path: "/path/to/file.mp3".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"completed\""));
// Verify camelCase field names
assert!(json.contains("\"downloadId\":42"), "Expected downloadId (camelCase), got: {}", json);
assert!(json.contains("\"itemId\":\"song456\""), "Expected itemId (camelCase), got: {}", json);
assert!(json.contains("\"filePath\":"), "Expected filePath (camelCase), got: {}", json);
// Verify roundtrip
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
match deserialized {
DownloadEvent::Completed { file_path, .. } => {
assert_eq!(file_path, "/path/to/file.mp3");
}
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_download_event_failed() {
let event = DownloadEvent::Failed {
download_id: 10,
item_id: "failed_item".to_string(),
error: "Network timeout".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"failed\""));
// Verify roundtrip
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
match deserialized {
DownloadEvent::Failed { error, .. } => {
assert_eq!(error, "Network timeout");
}
_ => panic!("Wrong variant"),
}
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Download manager for offline media support
//!
//! This module handles downloading media from Jellyfin servers with:
//! - Priority-based queue management
//! - Progress tracking and event emission
//! - Retry logic with exponential backoff
//! - Resume support via HTTP Range requests
pub mod cache;
pub mod events;
pub mod worker;
use std::path::PathBuf;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
pub use worker::DownloadWorker;
/// Download manager coordinating downloads across workers
pub struct DownloadManager {
/// Maximum concurrent downloads
max_concurrent: usize,
/// Currently active download IDs
active_downloads: Arc<Mutex<HashSet<i64>>>,
}
impl DownloadManager {
/// Create a new download manager
pub fn new(_media_dir: PathBuf) -> Self {
Self {
max_concurrent: 3,
active_downloads: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Get the maximum concurrent downloads
pub fn max_concurrent(&self) -> usize {
self.max_concurrent
}
/// Set the maximum concurrent downloads
pub fn set_max_concurrent(&mut self, max: usize) {
self.max_concurrent = max.max(1); // At least 1
}
/// Check if a new download can be started based on concurrent limit
pub fn can_start_download(&self) -> bool {
let active = self.active_downloads.lock().unwrap();
active.len() < self.max_concurrent
}
/// Get the number of currently active downloads
pub fn active_count(&self) -> usize {
self.active_downloads.lock().unwrap().len()
}
/// Register a download as active
pub fn register_download(&self, download_id: i64) -> bool {
let mut active = self.active_downloads.lock().unwrap();
if active.len() >= self.max_concurrent {
return false;
}
active.insert(download_id)
}
/// Unregister a download when it completes or fails
pub fn unregister_download(&self, download_id: i64) {
let mut active = self.active_downloads.lock().unwrap();
active.remove(&download_id);
}
/// Get a clone of the active downloads set (for internal use)
pub fn get_active_downloads(&self) -> Arc<Mutex<HashSet<i64>>> {
self.active_downloads.clone()
}
}
/// Information about a download
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadInfo {
pub id: i64,
pub item_id: String,
pub user_id: String,
pub file_path: String,
pub file_size: Option<i64>,
pub mime_type: Option<String>,
pub status: String,
pub progress: f64,
pub bytes_downloaded: i64,
pub queued_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub error_message: Option<String>,
pub retry_count: i32,
pub priority: i32,
// Item metadata for display (audio)
pub item_name: Option<String>,
pub artist_name: Option<String>,
pub album_name: Option<String>,
// Video-specific metadata
pub series_name: Option<String>,
pub season_name: Option<String>,
pub episode_number: Option<i32>,
pub season_number: Option<i32>,
pub quality_preset: Option<String>,
pub media_type: String,
// Download source tracking
pub download_source: String, // 'user' or 'auto'
}
/// Download task for workers
#[derive(Debug, Clone)]
pub struct DownloadTask {
pub url: String,
pub target_path: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::player::MediaType;
#[test]
fn test_download_manager_set_max_concurrent() {
let media_dir = PathBuf::from("/tmp/jellytau/media");
let mut manager = DownloadManager::new(media_dir);
manager.set_max_concurrent(5);
assert_eq!(manager.max_concurrent(), 5);
// Should clamp to minimum of 1
manager.set_max_concurrent(0);
assert_eq!(manager.max_concurrent(), 1);
}
#[test]
fn test_download_info_serialization() {
let info = DownloadInfo {
id: 1,
item_id: "test123".to_string(),
user_id: "user1".to_string(),
file_path: "/path/to/file.mp3".to_string(),
file_size: Some(1024000),
mime_type: Some("audio/mpeg".to_string()),
status: "downloading".to_string(),
progress: 0.5,
bytes_downloaded: 512000,
queued_at: "2024-01-01T00:00:00Z".to_string(),
started_at: Some("2024-01-01T00:01:00Z".to_string()),
completed_at: None,
error_message: None,
retry_count: 0,
priority: 0,
item_name: Some("Test Song".to_string()),
artist_name: Some("Test Artist".to_string()),
album_name: Some("Test Album".to_string()),
series_name: None,
season_name: None,
episode_number: None,
season_number: None,
quality_preset: Some("original".to_string()),
media_type: "audio".to_string(),
download_source: "user".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"status\":\"downloading\""));
assert!(json.contains("\"progress\":0.5"));
assert!(json.contains("\"itemName\":\"Test Song\""));
assert!(json.contains("\"mediaType\":\"audio\""));
}
#[test]
fn test_video_download_info_serialization() {
let info = DownloadInfo {
id: 2,
item_id: "episode123".to_string(),
user_id: "user1".to_string(),
file_path: "/path/to/ShowName/S01E01_Title.mp4".to_string(),
file_size: Some(1024000000),
mime_type: Some("video/mp4".to_string()),
status: "completed".to_string(),
progress: 1.0,
bytes_downloaded: 1024000000,
queued_at: "2024-01-01T00:00:00Z".to_string(),
started_at: Some("2024-01-01T00:01:00Z".to_string()),
completed_at: Some("2024-01-01T01:00:00Z".to_string()),
error_message: None,
retry_count: 0,
priority: 100,
item_name: Some("Episode Title".to_string()),
artist_name: None,
album_name: None,
series_name: Some("Show Name".to_string()),
season_name: Some("Season 1".to_string()),
episode_number: Some(1),
season_number: Some(1),
quality_preset: Some("high".to_string()),
media_type: "video".to_string(),
download_source: "auto".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"mediaType\":\"video\""));
assert!(json.contains("\"seriesName\":\"Show Name\""));
assert!(json.contains("\"episodeNumber\":1"));
assert!(json.contains("\"qualityPreset\":\"high\""));
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Download worker for HTTP streaming with progress tracking and retry logic
use log::warn;
use std::time::Duration;
use futures_util::StreamExt;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use super::DownloadTask;
/// Download worker that handles individual download tasks
pub struct DownloadWorker {
/// HTTP client for downloads
client: reqwest::Client,
/// Maximum retry attempts
max_retries: u32,
}
impl DownloadWorker {
pub fn new() -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minute timeout
.build()
.expect("Failed to create HTTP client");
Self {
client,
max_retries: 3,
}
}
/// Download a file with retry logic and progress tracking
pub async fn download(
&self,
task: &DownloadTask,
) -> Result<DownloadResult, DownloadError> {
let mut retries = 0;
loop {
match self.try_download(task).await {
Ok(result) => return Ok(result),
Err(e) if retries < self.max_retries && e.is_retryable() => {
retries += 1;
let delay = Self::exponential_backoff(retries);
warn!(
"Download failed (attempt {}/{}), retrying in {:?}: {}",
retries, self.max_retries, delay, e
);
tokio::time::sleep(delay).await;
}
Err(e) => return Err(e),
}
}
}
/// Attempt a single download
async fn try_download(&self, task: &DownloadTask) -> Result<DownloadResult, DownloadError> {
// Create parent directories
if let Some(parent) = task.target_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
}
// Check for partial download
let temp_path = task.target_path.with_extension("part");
let existing_bytes = if temp_path.exists() {
fs::metadata(&temp_path)
.await
.map(|m| m.len())
.unwrap_or(0)
} else {
0
};
// Build HTTP request with Range header for resume support
let mut request = self.client.get(&task.url);
if existing_bytes > 0 {
request = request.header("Range", format!("bytes={}-", existing_bytes));
}
// Send request
let response = request
.send()
.await
.map_err(|e| DownloadError::Network(e.to_string()))?;
// Check status
if !response.status().is_success() && response.status().as_u16() != 206 {
return Err(DownloadError::Http(response.status().as_u16()));
}
// Get content length
let _total_bytes = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(|len| if existing_bytes > 0 { len + existing_bytes } else { len });
// Open file for appending
let mut file = if existing_bytes > 0 {
fs::OpenOptions::new()
.append(true)
.open(&temp_path)
.await
} else {
fs::File::create(&temp_path).await
}
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
// Stream download with progress tracking
let mut downloaded = existing_bytes;
let mut stream = response.bytes_stream();
let mut last_progress_emit = std::time::Instant::now();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
file.write_all(&chunk)
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
downloaded += chunk.len() as u64;
// Emit progress every 500ms or every MB
if last_progress_emit.elapsed() > Duration::from_millis(500)
|| downloaded % (1024 * 1024) == 0
{
last_progress_emit = std::time::Instant::now();
// Progress events will be emitted by the manager
}
}
file.sync_all()
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
// Move from .part to final location
fs::rename(&temp_path, &task.target_path)
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
Ok(DownloadResult {
bytes_downloaded: downloaded,
})
}
/// Calculate exponential backoff delay
fn exponential_backoff(retry_count: u32) -> Duration {
let base_delay = 5; // 5 seconds
let delay_secs = base_delay * 3u64.pow(retry_count - 1); // 5s, 15s, 45s
Duration::from_secs(delay_secs)
}
}
/// Result of a successful download
#[derive(Debug)]
pub struct DownloadResult {
pub bytes_downloaded: u64,
}
/// Download error types
#[derive(Debug)]
pub enum DownloadError {
Network(String),
Http(u16),
FileSystem(String),
}
impl DownloadError {
/// Check if this error is retryable
fn is_retryable(&self) -> bool {
match self {
DownloadError::Network(_) => true,
DownloadError::Http(status) => *status >= 500, // Retry server errors
DownloadError::FileSystem(_) => false,
}
}
}
impl std::fmt::Display for DownloadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
DownloadError::Http(status) => write!(f, "HTTP error {}", status),
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
}
}
}
impl std::error::Error for DownloadError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exponential_backoff() {
assert_eq!(DownloadWorker::exponential_backoff(1), Duration::from_secs(5));
assert_eq!(DownloadWorker::exponential_backoff(2), Duration::from_secs(15));
assert_eq!(DownloadWorker::exponential_backoff(3), Duration::from_secs(45));
}
#[test]
fn test_error_retryable() {
assert!(DownloadError::Network("timeout".to_string()).is_retryable());
assert!(DownloadError::Http(500).is_retryable());
assert!(DownloadError::Http(503).is_retryable());
assert!(!DownloadError::Http(404).is_retryable());
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
}
}
+451
View File
@@ -0,0 +1,451 @@
use log::{debug, error, info};
use reqwest::Client;
use serde::Deserialize;
use std::sync::Arc;
use super::types::*;
const APP_NAME: &str = "JellyTau";
const APP_VERSION: &str = "0.1.0";
/// Jellyfin API client for playback reporting
#[derive(Clone)]
pub struct JellyfinClient {
config: Arc<JellyfinConfig>,
http_client: Client,
}
impl JellyfinClient {
/// Create a new Jellyfin API client
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
Ok(Self {
config: Arc::new(config),
http_client,
})
}
/// Get device name based on platform
fn get_device_name() -> &'static str {
#[cfg(target_os = "android")]
return "Android";
#[cfg(target_os = "linux")]
return "Linux";
#[cfg(target_os = "windows")]
return "Windows";
#[cfg(target_os = "macos")]
return "macOS";
#[cfg(target_os = "ios")]
return "iOS";
#[cfg(not(any(
target_os = "android",
target_os = "linux",
target_os = "windows",
target_os = "macos",
target_os = "ios"
)))]
return "Unknown";
}
/// Build the X-Emby-Authorization header value
fn get_auth_header(&self) -> String {
format!(
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
APP_NAME,
APP_VERSION,
Self::get_device_name(),
self.config.device_id,
self.config.access_token
)
}
/// Make a GET request to the Jellyfin API
async fn get<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, String> {
let url = format!("{}{}", self.config.server_url, endpoint);
log::debug!("[JellyfinClient] GET {}", endpoint);
let response = self.http_client
.get(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| {
log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
format!("Network request failed: {}", e)
})?;
let status = response.status();
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
log::error!("[JellyfinClient] Response: {}", error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
// Get the response text first so we can log it
let response_text = response.text().await.map_err(|e| {
log::error!("[JellyfinClient] Failed to read response body: {}", e);
format!("Failed to read response: {}", e)
})?;
// Log the raw response for sessions endpoint to help debug
if endpoint.contains("/Sessions") {
debug!("[JellyfinClient] Raw response for {}: {}", endpoint,
if response_text.len() > 500 {
format!("{}... (truncated, {} bytes total)", &response_text[..500], response_text.len())
} else {
response_text.clone()
}
);
}
// Parse the response text as JSON
let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
log::error!("[JellyfinClient] Failed to parse response: {}", e);
log::error!("[JellyfinClient] Response was: {}",
if response_text.len() > 200 {
format!("{}...", &response_text[..200])
} else {
response_text.clone()
}
);
format!("Failed to parse response: {}", e)
})?;
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
Ok(data)
}
/// Make a POST request to the Jellyfin API
async fn post<T: serde::Serialize>(&self, endpoint: &str, body: &T) -> Result<(), String> {
let url = format!("{}{}", self.config.server_url, endpoint);
log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
let response: reqwest::Response = self.http_client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.get_auth_header())
.json(body)
.send()
.await
.map_err(|e| {
log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
format!("Network request failed: {}", e)
})?;
let status = response.status();
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
if !status.is_success() {
let error_text: String = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
log::error!("[JellyfinClient] Response: {}", error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
Ok(())
}
/// Report playback start to Jellyfin
pub async fn report_playback_start(
&self,
item_id: String,
position_ticks: i64,
play_session_id: Option<String>,
) -> Result<(), String> {
let request = PlaybackStartRequest {
item_id,
position_ticks,
play_session_id,
play_command: "PlayNow".to_string(),
is_paused: false,
};
self.post("/Sessions/Playing", &request).await
}
/// Report playback stopped to Jellyfin
pub async fn report_playback_stopped(
&self,
item_id: String,
position_ticks: i64,
play_session_id: Option<String>,
) -> Result<(), String> {
let request = PlaybackStoppedRequest {
item_id,
position_ticks,
play_session_id,
};
self.post("/Sessions/Playing/Stopped", &request).await
}
/// Report playback progress to Jellyfin
#[allow(dead_code)] // Will be used when playback_reporting is integrated
pub async fn report_playback_progress(
&self,
item_id: String,
position_ticks: i64,
is_paused: bool,
play_session_id: Option<String>,
) -> Result<(), String> {
let request = PlaybackProgressRequest {
item_id,
position_ticks,
is_paused,
play_session_id,
};
self.post("/Sessions/Playing/Progress", &request).await
}
/// Play items on a remote session (casting)
pub async fn play_on_session(
&self,
session_id: String,
item_ids: Vec<String>,
start_index: usize,
start_position_ticks: Option<i64>,
) -> Result<(), String> {
log::info!("[JellyfinClient] Playing on session: {}", session_id);
log::info!("[JellyfinClient] Item IDs: {:?}, Start index: {}", item_ids, start_index);
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
session_id, item_ids.len(), start_index);
// Build URL with query parameters (Jellyfin expects query params, not JSON body!)
let mut url = format!(
"{}/Sessions/{}/Playing?playCommand=PlayNow&startIndex={}",
self.config.server_url, session_id, start_index
);
// Add item IDs as repeated query parameters
for item_id in &item_ids {
url.push_str(&format!("&itemIds={}", item_id));
}
// Add start position if provided
if let Some(ticks) = start_position_ticks {
url.push_str(&format!("&startPositionTicks={}", ticks));
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
}
log::info!("[JellyfinClient] POST {}", url);
debug!("[JellyfinClient] Full URL length: {} chars", url.len());
// Don't log full URL as it may contain sensitive tokens, just log the endpoint
debug!("[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds", session_id, item_ids.len());
debug!("[JellyfinClient] Sending HTTP POST request...");
let response = self.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| {
log::error!("[JellyfinClient] Request failed: {}", e);
error!("[JellyfinClient] HTTP request failed: {}", e);
format!("Network request failed: {}", e)
})?;
let status = response.status();
log::debug!("[JellyfinClient] Response status: {}", status);
debug!("[JellyfinClient] Response status: {}", status);
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {}", error_text);
error!("[JellyfinClient] API error {}: {}", status.as_u16(), error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
log::info!("[JellyfinClient] Successfully sent play command to remote session");
info!("[JellyfinClient] Play command sent to remote session");
Ok(())
}
/// Send a playback command to a remote session
pub async fn send_session_command(
&self,
session_id: String,
command: &str,
) -> Result<(), String> {
self.post(&format!("/Sessions/{}/Playing/{}", session_id, command), &serde_json::json!({})).await
}
/// Seek on a remote session
pub async fn session_seek(
&self,
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
#[derive(serde::Serialize)]
#[serde(rename_all = "PascalCase")]
struct SeekRequest {
seek_position_ticks: i64,
}
let request = SeekRequest {
seek_position_ticks: position_ticks,
};
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
}
/// Set volume on a remote session
pub async fn session_set_volume(
&self,
session_id: String,
volume: i32,
) -> Result<(), String> {
let payload = serde_json::json!({
"Arguments": {
"Volume": volume.to_string()
}
});
log::info!("[JellyfinClient] Setting volume on session {} to {} with payload: {}",
session_id, volume, serde_json::to_string(&payload).unwrap_or_default());
self.post(
&format!("/Sessions/{}/Command/SetVolume", session_id),
&payload
).await
}
/// Toggle mute on a remote session
pub async fn session_toggle_mute(
&self,
session_id: String,
) -> Result<(), String> {
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
self.post(
&format!("/Sessions/{}/Command/ToggleMute", session_id),
&serde_json::json!({})
).await
}
/// Get all active sessions
pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
info!("[JellyfinClient] Fetched {} sessions from API", sessions.len());
for session in &sessions {
debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
session.id, session.device_name, session.client, session.supports_remote_control);
}
Ok(sessions)
}
/// Get a specific session by ID
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
let sessions = self.get_sessions().await?;
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
}
}
/// Default value for supports_remote_control when missing from API
/// We default to true to show all sessions. If a session explicitly doesn't
/// support remote control, the Jellyfin API will set this field to false.
fn default_true() -> bool {
true
}
/// Session information from Jellyfin
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
pub struct SessionInfo {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub user_name: Option<String>,
#[serde(default)]
pub client: Option<String>,
#[serde(default)]
pub device_name: Option<String>,
#[serde(default)]
pub device_id: Option<String>,
#[serde(default)]
pub application_version: Option<String>,
#[serde(default)]
pub is_active: Option<bool>,
#[serde(default)]
pub supports_media_control: Option<bool>,
#[serde(default = "default_true")]
pub supports_remote_control: bool,
#[serde(default)]
pub now_playing_item: Option<NowPlayingItem>,
#[serde(default)]
pub play_state: Option<PlayState>,
#[serde(default)]
pub playable_media_types: Option<Vec<String>>,
#[serde(default)]
pub supported_commands: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
pub struct NowPlayingItem {
pub id: Option<String>,
pub name: Option<String>,
pub run_time_ticks: Option<i64>,
pub album: Option<String>,
pub album_id: Option<String>,
pub album_artist: Option<String>,
pub artists: Option<Vec<String>>,
pub image_tags: Option<std::collections::HashMap<String, String>>,
pub primary_image_tag: Option<String>,
pub album_primary_image_tag: Option<String>,
#[serde(rename = "Type")]
pub item_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
pub struct PlayState {
#[serde(default)]
pub position_ticks: Option<i64>,
#[serde(default)]
pub can_seek: Option<bool>,
#[serde(default)]
pub is_paused: Option<bool>,
#[serde(default)]
pub is_muted: Option<bool>,
#[serde(default)]
pub volume_level: Option<i32>,
#[serde(default)]
pub repeat_mode: Option<String>,
#[serde(default)]
pub shuffle_mode: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_header_format() {
let config = JellyfinConfig {
server_url: "http://localhost:8096".to_string(),
access_token: "test_token".to_string(),
device_id: "device456".to_string(),
};
let client = JellyfinClient::new(config).unwrap();
let header = client.get_auth_header();
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
assert!(header.contains("Token=\"test_token\""));
assert!(header.contains("DeviceId=\"device456\""));
}
}
+262
View File
@@ -0,0 +1,262 @@
use reqwest::{Client, Request, Response, StatusCode};
use serde::de::DeserializeOwned;
use std::time::Duration;
const APP_NAME: &str = "JellyTau";
const APP_VERSION: &str = "0.1.0";
// Default timeout for requests (10 seconds)
const DEFAULT_TIMEOUT_MS: u64 = 10000;
// Retry configuration - matches TypeScript exactly
const DEFAULT_MAX_RETRIES: u32 = 3;
const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 4000]; // Exponential backoff
/// HTTP client configuration
#[derive(Clone, Debug)]
pub struct HttpConfig {
pub timeout: Duration,
pub max_retries: u32,
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
max_retries: DEFAULT_MAX_RETRIES,
}
}
}
/// Error classification for retry logic
#[derive(Debug, Clone, PartialEq)]
pub enum ErrorKind {
Network,
Authentication,
Server,
Client,
}
/// Enhanced HTTP client with retry logic and error classification
#[derive(Clone)]
pub struct HttpClient {
pub(crate) client: Client, // Make accessible within crate for custom requests
config: HttpConfig,
}
impl HttpClient {
/// Create a new HTTP client with default configuration
pub fn new(config: HttpConfig) -> Result<Self, String> {
let client = Client::builder()
.timeout(config.timeout)
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
Ok(Self { client, config })
}
/// Get device name based on platform
fn get_device_name() -> &'static str {
#[cfg(target_os = "android")]
return "Android";
#[cfg(target_os = "linux")]
return "Linux";
#[cfg(target_os = "windows")]
return "Windows";
#[cfg(target_os = "macos")]
return "macOS";
#[cfg(target_os = "ios")]
return "iOS";
#[cfg(not(any(
target_os = "android",
target_os = "linux",
target_os = "windows",
target_os = "macos",
target_os = "ios"
)))]
return "Unknown";
}
/// Build the X-Emby-Authorization header value
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
let mut parts = vec![
format!("MediaBrowser Client=\"{}\"", APP_NAME),
format!("Version=\"{}\"", APP_VERSION),
format!("Device=\"{}\"", Self::get_device_name()),
format!("DeviceId=\"{}\"", device_id),
];
if let Some(token) = access_token {
parts.push(format!("Token=\"{}\"", token));
}
parts.join(", ")
}
/// Classify an error for retry logic
pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
// Network errors (connection failures, timeouts, DNS failures)
if error.is_timeout() || error.is_connect() {
return ErrorKind::Network;
}
// Check status code if available
if let Some(status) = error.status() {
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
return ErrorKind::Authentication;
} else if status.is_server_error() {
return ErrorKind::Server;
} else if status.is_client_error() {
return ErrorKind::Client;
}
}
// If no status code, check error message for network-related keywords
let error_msg = error.to_string().to_lowercase();
if error_msg.contains("network")
|| error_msg.contains("connection")
|| error_msg.contains("timeout")
|| error_msg.contains("dns")
|| error_msg.contains("refused")
|| error_msg.contains("reset")
{
return ErrorKind::Network;
}
// Default to client error
ErrorKind::Client
}
/// Check if a request should be retried based on the error
pub fn should_retry(error: &reqwest::Error) -> bool {
match Self::classify_error(error) {
ErrorKind::Network => true, // Retry network errors
ErrorKind::Server => true, // Retry 5xx server errors
ErrorKind::Authentication => false, // Don't retry 401/403
ErrorKind::Client => false, // Don't retry other 4xx errors
}
}
/// Make a request with automatic retry on network errors
pub async fn request_with_retry(
&self,
request: Request,
) -> Result<Response, reqwest::Error> {
let max_retries = self.config.max_retries;
let mut last_error: Option<reqwest::Error> = None;
for attempt in 0..=max_retries {
// Clone the request for retry attempts
// If request cannot be cloned (e.g., streaming body), we cannot retry
let Some(req) = request.try_clone() else {
log::warn!("[HttpClient] Request body cannot be cloned, retries not possible");
return self.client.execute(request).await;
};
match self.client.execute(req).await {
Ok(response) => return Ok(response),
Err(error) => {
last_error = Some(error);
let err = last_error.as_ref().unwrap();
// Don't retry if it's not a retryable error
if !Self::should_retry(err) {
return Err(last_error.unwrap());
}
// Don't retry on last attempt
if attempt == max_retries {
break;
}
// Wait before retrying (exponential backoff)
let delay_ms = RETRY_DELAYS_MS
.get(attempt as usize)
.copied()
.unwrap_or(*RETRY_DELAYS_MS.last().unwrap());
log::info!(
"[HttpClient] Retry {}/{} after {}ms (error: {})",
attempt + 1,
max_retries,
delay_ms,
err
);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
}
}
Err(last_error.unwrap())
}
/// Make a GET request with retry
pub async fn get_with_retry(&self, url: &str) -> Result<Response, reqwest::Error> {
let request = self.client.get(url).build()?;
self.request_with_retry(request).await
}
/// Make a GET request and deserialize JSON response with retry
pub async fn get_json_with_retry<T: DeserializeOwned>(
&self,
url: &str,
) -> Result<T, String> {
let response = self.get_with_retry(url).await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("HTTP {}: {}", status, error_text));
}
response.json::<T>().await
.map_err(|e| format!("Failed to parse JSON: {}", e))
}
/// Quick ping to check if a server is reachable (no retry)
pub async fn ping(&self, url: &str) -> bool {
let request = self.client.get(url)
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
.build();
match request {
Ok(req) => {
match self.client.execute(req).await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
Err(_) => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_header_format() {
let header = HttpClient::build_auth_header(Some("test_token"), "device456");
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
assert!(header.contains("Token=\"test_token\""));
assert!(header.contains("DeviceId=\"device456\""));
}
#[test]
fn test_auth_header_without_token() {
let header = HttpClient::build_auth_header(None, "device456");
assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
assert!(!header.contains("Token="));
assert!(header.contains("DeviceId=\"device456\""));
}
#[test]
fn test_retry_delays() {
// Verify retry delays match TypeScript
assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod client;
pub mod http_client;
pub mod types;
pub use client::{JellyfinClient, NowPlayingItem};
pub use http_client::{HttpClient, HttpConfig};
pub use types::*;
+43
View File
@@ -0,0 +1,43 @@
use serde::Serialize;
/// Configuration for Jellyfin API client
#[derive(Debug, Clone)]
pub struct JellyfinConfig {
pub server_url: String,
pub access_token: String,
pub device_id: String,
}
/// Request body for reporting playback start
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct PlaybackStartRequest {
pub item_id: String,
pub position_ticks: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub play_session_id: Option<String>,
pub play_command: String,
pub is_paused: bool,
}
/// Request body for reporting playback stopped
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct PlaybackStoppedRequest {
pub item_id: String,
pub position_ticks: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub play_session_id: Option<String>,
}
/// Request body for reporting playback progress
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
#[allow(dead_code)] // Will be used when playback_reporting is integrated
pub struct PlaybackProgressRequest {
pub item_id: String,
pub position_ticks: i64,
pub is_paused: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub play_session_id: Option<String>,
}
+776
View File
@@ -0,0 +1,776 @@
mod auth;
mod commands;
mod connectivity;
mod credentials;
mod download;
mod jellyfin;
mod playback_mode;
mod playback_reporting;
mod player;
mod repository;
mod session_poller;
pub mod settings;
mod storage;
mod thumbnail;
mod utils;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
use tauri::Manager;
use log::{error, info};
#[cfg(target_os = "android")]
use log::warn;
use commands::{
cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download,
download_album, download_item, download_item_and_start, download_video, download_series, download_season,
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
get_album_affinity_status,
mark_download_completed, mark_download_failed, start_download,
pin_item, unpin_item, is_item_pinned,
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
player_get_video_settings, player_next, player_pause, player_play, player_play_album_track,
player_play_item, player_play_queue, player_play_tracks, player_previous, player_seek, player_seek_video, player_set_audio_settings, player_set_audio_track, player_switch_audio_track,
player_set_subtitle_track, player_set_video_settings, player_set_volume, player_toggle_mute, player_stop, player_toggle,
player_toggle_shuffle,
// Sleep timer and autoplay commands
player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer,
player_get_autoplay_settings, player_set_autoplay_settings,
player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended,
// Queue manipulation commands
player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids,
player_remove_from_queue, player_move_in_queue, player_skip_to,
// Preload commands
player_preload_upcoming, player_set_cache_config, player_get_cache_config,
// Jellyfin reporting commands
player_configure_jellyfin, player_disable_jellyfin,
// Session management commands
player_get_session, player_dismiss_session,
// Remote session control commands
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
remote_session_toggle_mute,
// Session polling commands
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
// Playback mode commands
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local,
playback_mode_get_remote_status,
// Playback reporting commands
playback_reporter_init, playback_reporter_destroy,
playback_report_start, playback_report_progress, playback_report_stopped,
playback_mark_played, PlaybackReporterWrapper,
// Auth commands
auth_initialize, auth_connect_to_server, auth_login, auth_verify_session,
auth_logout, auth_get_session, auth_set_session, auth_start_verification,
auth_stop_verification, auth_reauthenticate,
// Connectivity commands
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
connectivity_start_monitoring, connectivity_stop_monitoring,
connectivity_mark_reachable, connectivity_mark_unreachable,
// Storage commands
storage_delete_server, storage_delete_user, storage_get_access_token,
storage_get_active_session, storage_get_active_user, storage_get_path,
storage_get_playback_progress, storage_get_security_status, storage_get_servers, storage_get_size,
storage_get_users, storage_init, storage_mark_played, storage_mark_synced, storage_save_server,
storage_save_user, storage_set_active_user, storage_toggle_favorite, storage_update_playback_progress,
storage_update_playback_context,
// Offline cache commands
storage_get_libraries, storage_get_items, storage_get_item, storage_search_items,
storage_save_library, storage_save_item, storage_get_pending_sync_count,
// Sync queue commands
sync_queue_mutation, sync_get_pending, sync_mark_processing, sync_mark_completed,
sync_mark_failed, sync_get_pending_count, sync_cleanup_completed, sync_clear_user,
// Thumbnail cache and image commands
thumbnail_get_cached, thumbnail_save, thumbnail_get_stats, thumbnail_set_limit,
thumbnail_clear_cache, thumbnail_delete_item, image_get_url,
// People cache commands
storage_save_person, storage_get_person, storage_save_item_people, storage_get_item_people,
// Series audio preferences
storage_save_series_audio_preference, storage_get_series_audio_preference,
// Repository commands
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, repository_get_latest_items, repository_get_resume_items,
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
repository_get_genres, repository_search, repository_get_playback_info,
repository_get_video_stream_url, repository_get_audio_stream_url,
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
// Conversion commands
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
calc_progress, convert_percent_to_volume,
AuthManagerWrapper, SessionVerifierWrapper,
ConnectivityMonitorWrapper, CredentialStoreWrapper, DatabaseWrapper, PlayerStateWrapper,
MediaSessionManagerWrapper, VideoSettingsWrapper, ThumbnailCacheWrapper, SmartCacheWrapper,
PlaybackModeManagerWrapper, RepositoryManagerWrapper, DownloadManagerWrapper,
};
#[cfg(target_os = "android")]
use playback_mode::PlaybackModeManager;
use auth::AuthManager;
use connectivity::ConnectivityMonitor;
use credentials::CredentialStore;
use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
use download::DownloadManager;
use jellyfin::{HttpClient, HttpConfig};
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
// NullBackend fallback for platforms without native backends (not Linux or Android)
#[cfg(not(any(target_os = "linux", target_os = "android")))]
use player::NullBackend;
#[cfg(target_os = "linux")]
use player::MpvBackend;
use settings::VideoSettings;
use storage::Database;
use thumbnail::{ThumbnailCache, CacheConfig as ThumbnailCacheConfig};
#[cfg(target_os = "android")]
use credentials::initialize_secure_storage;
#[cfg(target_os = "android")]
use player::ExoPlayerBackend;
#[cfg(target_os = "android")]
use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler, set_remote_volume_handler};
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
///
/// Routes commands from the system media controls back to the PlayerController.
#[cfg(target_os = "android")]
struct MediaSessionHandler {
player: Arc<TokioMutex<PlayerController>>,
}
#[cfg(target_os = "android")]
impl MediaCommandHandler for MediaSessionHandler {
fn on_command(&self, command: &str) {
// Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock();
match command {
"play" => {
if let Err(e) = controller.play() {
error!("[MediaSession] Play failed: {}", e);
}
}
"pause" => {
if let Err(e) = controller.pause() {
error!("[MediaSession] Pause failed: {}", e);
}
}
"next" => {
if let Err(e) = controller.next() {
error!("[MediaSession] Next failed: {}", e);
}
}
"previous" => {
if let Err(e) = controller.previous() {
error!("[MediaSession] Previous failed: {}", e);
}
}
"stop" => {
if let Err(e) = controller.stop() {
error!("[MediaSession] Stop failed: {}", e);
}
}
cmd if cmd.starts_with("seek:") => {
if let Ok(pos) = cmd[5..].parse::<f64>() {
if let Err(e) = controller.seek(pos) {
error!("[MediaSession] Seek failed: {}", e);
}
}
}
_ => {
warn!("[MediaSession] Unknown command: {}", command);
}
}
}
}
/// Handler for remote volume changes from Android volume buttons when in remote playback mode.
///
/// Routes volume commands to the Jellyfin session via the playback mode manager.
#[cfg(target_os = "android")]
struct RemoteVolumeSessionHandler {
playback_mode: Arc<PlaybackModeManager>,
}
#[cfg(target_os = "android")]
impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
fn on_remote_volume_change(&self, command: &str, volume: i32) {
log::info!("[RemoteVolume] Command: {}, Volume: {}", command, volume);
// Send the volume command to the remote session asynchronously
let playback_mode = Arc::clone(&self.playback_mode);
let command_str = command.to_string();
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
log::info!("[RemoteVolume] Spawning async task to send volume command...");
tauri::async_runtime::spawn(async move {
log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
match playback_mode.send_remote_volume_command(&command_str, volume).await {
Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
}
log::info!("[RemoteVolume] Async task completed");
});
log::info!("[RemoteVolume] Async task spawned, returning from JNI callback");
}
}
/// Create the appropriate player backend for the current platform.
fn create_player_backend(
app_handle: tauri::AppHandle,
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
position_throttler: Arc<playback_reporting::EventThrottler>,
) -> Box<dyn PlayerBackend> {
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
#[cfg(target_os = "android")]
{
info!("Android platform detected - initializing ExoPlayer backend");
// Get the Android context via ndk-context
let ctx = ndk_context::android_context();
// Get JavaVM and create JNI environment
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
match vm {
Ok(java_vm) => {
match java_vm.attach_current_thread() {
Ok(mut env) => {
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
match ExoPlayerBackend::new(&mut env, &context_obj, _event_emitter.clone(), playback_reporter.clone(), position_throttler.clone()) {
Ok(backend) => {
info!("Successfully initialized ExoPlayer backend for Android");
return Box::new(backend);
}
Err(e) => {
panic!("FATAL: Failed to initialize ExoPlayer backend on Android: {}. This is a critical error - playback will not work.", e);
}
}
}
Err(e) => {
panic!("FATAL: Failed to attach JNI thread on Android: {}. This is a critical error - playback will not work.", e);
}
}
}
Err(e) => {
panic!("FATAL: Failed to create JavaVM on Android: {}. This is a critical error - playback will not work.", e);
}
}
}
// For Linux, use MPV backend for audio playback
#[cfg(target_os = "linux")]
{
info!("Linux platform detected - initializing MPV backend for audio");
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
Ok(backend) => {
info!("Successfully initialized MPV backend for Linux");
return Box::new(backend);
}
Err(e) => {
error!("\n========================================");
error!("FATAL ERROR: Failed to initialize MPV backend");
error!("========================================");
error!("Error: {}", e);
error!("\nCommon causes:");
error!(" 1. MPV is not installed");
error!(" Solution: Install MPV using your package manager");
error!(" - Arch/CachyOS: sudo pacman -S mpv");
error!(" - Ubuntu/Debian: sudo apt install mpv libmpv-dev");
error!(" - Fedora: sudo dnf install mpv mpv-libs-devel");
error!("\n 2. MPV version mismatch (app was built with different libmpv version)");
error!(" Solution: Rebuild the application");
error!(" - cd src-tauri && cargo clean && cargo build --release");
error!("\n 3. Audio system not working");
error!(" Solution: Verify audio works with: pactl info");
error!("\nAudio playback will NOT work until this is fixed.");
error!("========================================\n");
panic!("Cannot start application: MPV backend initialization failed. See error message above.");
}
}
}
// Fallback for other platforms
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
warn!("WARNING: No audio backend available for this platform");
Box::new(NullBackend::new())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize logger
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.setup(|app| {
// Initialize database with proper app data directory
// Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
let test_path = std::path::PathBuf::from(test_data_dir);
info!("[INIT] Using test data directory: {:?}", test_path);
test_path.join("jellytau.db")
} else {
app
.path()
.app_data_dir()
.expect("Failed to get app data directory")
.join("jellytau.db")
};
info!("[INIT] Initializing database at: {:?}", db_path);
// Create the directory if it doesn't exist
if let Some(parent) = db_path.parent() {
info!("[INIT] Creating database directory: {:?}", parent);
match std::fs::create_dir_all(parent) {
Ok(_) => info!("[INIT] Database directory ready"),
Err(e) => {
error!("[INIT ERROR] Failed to create database directory: {}", e);
panic!("Failed to create database directory: {}", e);
}
}
}
info!("[INIT] Opening database...");
let database = match Database::open(&db_path) {
Ok(db) => {
info!("[INIT] Database initialized successfully");
db
}
Err(e) => {
error!("[INIT ERROR] Failed to initialize database: {}", e);
panic!("Failed to initialize database: {}", e);
}
};
let db_wrapper = DatabaseWrapper(Mutex::new(database));
app.manage(db_wrapper);
// On Android, initialize SecureStorage BEFORE creating CredentialStore
#[cfg(target_os = "android")]
{
info!("[INIT] Initializing Android SecureStorage for credentials...");
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
match vm {
Ok(java_vm) => {
match java_vm.attach_current_thread() {
Ok(mut env) => {
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
match initialize_secure_storage(&mut env, &context_obj) {
Ok(()) => {
info!("[INIT] Android SecureStorage initialized successfully");
}
Err(e) => {
warn!("[INIT WARNING] Failed to initialize SecureStorage: {}. Credentials will use encrypted file fallback.", e);
}
}
}
Err(e) => {
warn!("[INIT WARNING] Failed to attach JNI thread: {}. Credentials will use encrypted file fallback.", e);
}
}
}
Err(e) => {
warn!("[INIT WARNING] Failed to create JavaVM: {}. Credentials will use encrypted file fallback.", e);
}
}
}
// Initialize credential store (keyring with encrypted file fallback)
info!("[INIT] Initializing credential store...");
let credential_store = CredentialStore::new();
let creds_wrapper = CredentialStoreWrapper(Mutex::new(credential_store));
app.manage(creds_wrapper);
// Create shared reporter and throttler Arc wrappers before backend/controller
info!("[INIT] Creating shared playback reporting infrastructure...");
let playback_reporter = Arc::new(tokio::sync::Mutex::new(None));
let position_throttler = Arc::new(playback_reporting::EventThrottler::new());
// Create player backend with access to AppHandle for event emission
info!("[INIT] Creating player backend...");
let backend = create_player_backend(
app.handle().clone(),
playback_reporter.clone(),
position_throttler.clone(),
);
let player_controller = PlayerController::new(
backend,
playback_reporter.clone(),
position_throttler.clone(),
);
// Wire up event emitter for sleep timer and autoplay notifications
let event_emitter = Arc::new(TauriEventEmitter::new(app.handle().clone()));
player_controller.set_event_emitter(event_emitter.clone());
let player_arc = Arc::new(TokioMutex::new(player_controller));
// On Android, set up the MediaSession handler for lockscreen controls
#[cfg(target_os = "android")]
{
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
let handler = Arc::new(MediaSessionHandler {
player: player_arc.clone(),
});
set_media_command_handler(handler);
// Register player controller for autoplay decisions
player::android::set_player_controller(player_arc.clone());
}
let player_state = PlayerStateWrapper(player_arc.clone());
app.manage(player_state);
// Initialize media session manager
info!("[INIT] Initializing media session manager...");
let session_manager = MediaSessionManager::new();
let session_wrapper = MediaSessionManagerWrapper(Mutex::new(session_manager));
app.manage(session_wrapper);
// Initialize playback mode manager
info!("[INIT] Initializing playback mode manager...");
let jellyfin_client = {
let player = player_arc.blocking_lock();
player.jellyfin_client()
};
let playback_mode_manager = playback_mode::PlaybackModeManager::new(
jellyfin_client.clone(),
player_arc.clone(),
);
let playback_mode_arc = Arc::new(playback_mode_manager);
let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
app.manage(playback_mode_wrapper);
// Initialize session poller manager for remote session polling
info!("[INIT] Initializing session poller manager...");
let session_poller = session_poller::SessionPollerManager::new(
jellyfin_client,
playback_mode_arc.clone(),
);
session_poller.set_event_emitter(event_emitter.clone());
session_poller.start();
let session_poller_arc = Arc::new(session_poller);
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
app.manage(session_poller_wrapper);
// On Android, set up remote volume handler for volume button intercept in remote mode
#[cfg(target_os = "android")]
{
info!("[INIT] Setting up remote volume handler for Android...");
let handler = Arc::new(RemoteVolumeSessionHandler {
playback_mode: playback_mode_arc.clone(),
});
set_remote_volume_handler(handler);
}
// Initialize video settings with defaults
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
app.manage(video_settings);
// Initialize thumbnail cache
info!("[INIT] Initializing thumbnail cache...");
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
std::path::PathBuf::from(test_data_dir)
} else {
app
.path()
.app_data_dir()
.expect("Failed to get app data directory")
};
let thumbnail_cache = ThumbnailCache::new(app_data_dir.clone(), ThumbnailCacheConfig::default());
let thumbnail_wrapper = ThumbnailCacheWrapper(Arc::new(thumbnail_cache));
app.manage(thumbnail_wrapper);
// Initialize smart cache for preloading
info!("[INIT] Initializing smart cache...");
let smart_cache = SmartCache::new(SmartCacheConfig::default());
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
app.manage(smart_cache_wrapper);
// Initialize download manager
info!("[INIT] Initializing download manager...");
let download_dir = app_data_dir.join("downloads");
let download_manager = DownloadManager::new(download_dir);
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
app.manage(download_manager_wrapper);
// Initialize connectivity monitor
info!("[INIT] Initializing connectivity monitor...");
let http_config = HttpConfig::default();
let http_client = HttpClient::new(http_config)
.expect("Failed to create HTTP client");
let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
connectivity_monitor.set_app_handle(app.handle().clone());
// Wrap in Arc for sharing with AuthManager
let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
app.manage(connectivity_wrapper);
// Initialize auth manager
info!("[INIT] Initializing auth manager...");
let auth_http_config = HttpConfig::default();
let auth_http_client = HttpClient::new(auth_http_config)
.expect("Failed to create HTTP client for auth");
let mut auth_manager = AuthManager::new(auth_http_client);
// Give auth manager a reference to connectivity monitor
auth_manager.set_connectivity_monitor(connectivity_arc.clone());
let auth_manager_wrapper = AuthManagerWrapper(Arc::new(auth_manager));
app.manage(auth_manager_wrapper);
// Initialize session verifier wrapper (initially empty)
info!("[INIT] Initializing session verifier wrapper...");
let session_verifier_wrapper = SessionVerifierWrapper(Arc::new(tokio::sync::Mutex::new(None)));
app.manage(session_verifier_wrapper);
// Initialize repository manager
info!("[INIT] Initializing repository manager...");
let repository_manager = commands::RepositoryManager::new();
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
app.manage(repository_manager_wrapper);
// Initialize playback reporter wrapper (initially empty, set on login)
info!("[INIT] Initializing playback reporter wrapper...");
let playback_reporter_wrapper = PlaybackReporterWrapper(Arc::new(tokio::sync::Mutex::new(None)));
app.manage(playback_reporter_wrapper);
info!("[INIT] Application setup completed successfully");
Ok(())
})
.invoke_handler(tauri::generate_handler![
// Player commands
player_play_item,
player_play_queue,
player_play_album_track,
player_play_tracks,
player_play,
player_pause,
player_toggle,
player_stop,
player_next,
player_previous,
player_seek,
player_seek_video,
player_set_volume,
player_toggle_mute,
player_set_audio_track,
player_switch_audio_track,
player_set_subtitle_track,
player_toggle_shuffle,
player_cycle_repeat,
player_get_status,
player_get_queue,
player_add_to_queue,
player_add_track_by_id,
player_add_tracks_by_ids,
player_remove_from_queue,
player_move_in_queue,
player_skip_to,
player_set_audio_settings,
player_get_audio_settings,
player_set_video_settings,
player_get_video_settings,
// Sleep timer and autoplay commands
player_set_sleep_timer,
player_cancel_sleep_timer,
player_get_sleep_timer,
player_get_autoplay_settings,
player_set_autoplay_settings,
player_cancel_autoplay_countdown,
player_play_next_episode,
player_on_playback_ended,
// Preload commands
player_preload_upcoming,
player_set_cache_config,
player_get_cache_config,
// Jellyfin reporting commands
player_configure_jellyfin,
player_disable_jellyfin,
// Session management commands
player_get_session,
player_dismiss_session,
// Remote session control commands
remote_play_on_session,
remote_send_command,
remote_session_seek,
remote_session_set_volume,
remote_session_toggle_mute,
// Session polling commands
sessions_set_polling_hint,
sessions_poll_now,
// Playback mode commands
playback_mode_get_current,
playback_mode_set,
playback_mode_is_transferring,
playback_mode_transfer_to_remote,
playback_mode_get_remote_status,
playback_mode_transfer_to_local,
// Playback reporting commands
playback_reporter_init,
playback_reporter_destroy,
playback_report_start,
playback_report_progress,
playback_report_stopped,
playback_mark_played,
// Auth commands
auth_initialize,
auth_connect_to_server,
auth_login,
auth_verify_session,
auth_logout,
auth_get_session,
auth_set_session,
auth_start_verification,
auth_stop_verification,
auth_reauthenticate,
// Connectivity commands
connectivity_check_server,
connectivity_set_server_url,
connectivity_get_status,
connectivity_start_monitoring,
connectivity_stop_monitoring,
connectivity_mark_reachable,
connectivity_mark_unreachable,
// Storage commands
storage_init,
storage_get_path,
storage_get_size,
storage_get_security_status,
storage_save_server,
storage_get_servers,
storage_delete_server,
storage_save_user,
storage_get_users,
storage_set_active_user,
storage_get_active_user,
storage_get_active_session,
storage_get_access_token,
storage_delete_user,
// Playback progress commands
storage_update_playback_progress,
storage_update_playback_context,
storage_mark_played,
storage_get_playback_progress,
storage_mark_synced,
storage_toggle_favorite,
// Download commands
download_item,
download_item_and_start,
download_album,
download_video,
download_series,
download_season,
get_downloads,
pause_download,
resume_download,
cancel_download,
delete_download,
delete_all_downloads,
delete_album_downloads,
clear_stale_downloads,
get_download_storage_stats,
mark_download_completed,
mark_download_failed,
start_download,
get_download_manager_stats,
set_max_concurrent_downloads,
get_smart_cache_stats,
update_smart_cache_config,
get_smart_cache_config,
get_album_recommendations,
get_album_affinity_status,
// Pinning commands
pin_item,
unpin_item,
is_item_pinned,
// Offline commands
offline_is_available,
offline_get_items,
offline_search,
// Offline cache commands
storage_get_libraries,
storage_get_items,
storage_get_item,
storage_search_items,
storage_save_library,
storage_save_item,
storage_get_pending_sync_count,
// Sync queue commands
sync_queue_mutation,
sync_get_pending,
sync_mark_processing,
sync_mark_completed,
sync_mark_failed,
sync_get_pending_count,
sync_cleanup_completed,
sync_clear_user,
// Thumbnail cache and image commands
thumbnail_get_cached,
thumbnail_save,
thumbnail_get_stats,
thumbnail_set_limit,
thumbnail_clear_cache,
thumbnail_delete_item,
image_get_url,
// People cache commands
storage_save_person,
storage_get_person,
storage_save_item_people,
storage_get_item_people,
// Series audio preferences
storage_save_series_audio_preference,
storage_get_series_audio_preference,
// Repository commands
repository_create,
repository_destroy,
repository_get_libraries,
repository_get_items,
repository_get_item,
repository_get_latest_items,
repository_get_resume_items,
repository_get_next_up_episodes,
repository_get_recently_played_audio,
repository_get_resume_movies,
repository_get_genres,
repository_search,
repository_get_playback_info,
repository_get_video_stream_url,
repository_get_audio_stream_url,
repository_report_playback_start,
repository_report_playback_progress,
repository_report_playback_stopped,
repository_get_image_url,
repository_mark_favorite,
repository_unmark_favorite,
repository_get_person,
repository_get_items_by_person,
repository_get_similar_items,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
convert_ticks_to_seconds,
calc_progress,
convert_percent_to_volume,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
jellytau_lib::run()
}
+757
View File
@@ -0,0 +1,757 @@
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex, RwLock,
};
use tokio::sync::Mutex as TokioMutex;
use tokio::time::{sleep, Duration};
use crate::jellyfin::JellyfinClient;
use crate::player::{PlayerController, QueueContext};
/// Playback mode - local device, remote session, or idle
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PlaybackMode {
Local,
Remote { session_id: String },
Idle,
}
/// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
player_controller: Arc<TokioMutex<PlayerController>>,
current_mode: Arc<RwLock<PlaybackMode>>,
is_transferring: Arc<AtomicBool>,
}
impl PlaybackModeManager {
/// Create a new playback mode manager
pub fn new(
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
player_controller: Arc<TokioMutex<PlayerController>>,
) -> Self {
Self {
jellyfin_client,
player_controller,
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)),
}
}
/// Get current playback mode
pub fn get_mode(&self) -> PlaybackMode {
self.current_mode.read().unwrap().clone()
}
/// Set playback mode (internal use)
pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let mut current = self.current_mode.write().unwrap();
*current = mode;
}
/// Check if currently transferring
pub fn is_transferring(&self) -> bool {
self.is_transferring.load(Ordering::Relaxed)
}
/// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback
pub async fn send_remote_volume_command(&self, command: &str, volume: i32) -> Result<(), String> {
log::info!("[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume);
// Get the current session ID
let session_id = match self.get_mode() {
PlaybackMode::Remote { session_id } => session_id,
_ => {
log::warn!("[PlaybackMode] Ignoring remote volume command - not in remote mode");
return Ok(());
}
};
log::info!("[PlaybackMode] Current mode is Remote, session_id={}", session_id);
// Get Jellyfin client
let client = {
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
log::info!("[PlaybackMode] Jellyfin client lock acquired");
match client_opt.as_ref() {
Some(c) => {
log::info!("[PlaybackMode] Jellyfin client is configured, cloning...");
c.clone()
}
None => {
log::error!("[PlaybackMode] Jellyfin client is NOT configured!");
return Err("Jellyfin client not configured".to_string());
}
}
};
log::info!("[PlaybackMode] About to call client.session_set_volume...");
// Send the volume command
log::info!("[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume);
let result = client.session_set_volume(session_id, volume).await;
match &result {
Ok(_) => log::info!("[PlaybackMode] session_set_volume returned Ok"),
Err(e) => log::error!("[PlaybackMode] session_set_volume returned Err: {}", e),
}
result
}
/// Extract Jellyfin item IDs from queue items
/// Returns (item_ids, adjusted_current_index)
fn extract_jellyfin_ids(&self, items: &[crate::player::MediaItem], original_index: usize) -> Result<(Vec<String>, usize), String> {
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
let mut jellyfin_item_count = 0;
for (i, item) in items.iter().enumerate() {
if let Some(id) = item.jellyfin_id() {
jellyfin_ids.push(id.to_string());
// If this is the currently playing item, record its new index
if i == original_index {
adjusted_index = Some(jellyfin_item_count);
}
jellyfin_item_count += 1;
}
}
// Ensure the currently playing item has a Jellyfin ID
let final_index = match adjusted_index {
Some(idx) => idx,
None => {
log::warn!(
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
original_index
);
return Err("Cannot transfer: currently playing item is not from Jellyfin".to_string());
}
};
log::info!(
"[PlaybackMode] Extracted {} Jellyfin IDs from queue (original index: {} -> adjusted: {})",
jellyfin_ids.len(),
original_index,
final_index
);
Ok((jellyfin_ids, final_index))
}
/// Transfer playback from local device to remote Jellyfin session
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> {
debug!("[PlaybackMode] transfer_to_remote ENTERED");
debug!("[PlaybackMode] session_id: {}", session_id);
log::info!(
"[PlaybackMode] Transferring to remote session: {}",
session_id
);
// Set transferring flag
debug!("[PlaybackMode] Setting is_transferring flag");
self.is_transferring.store(true, Ordering::Relaxed);
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer
let result = self.transfer_to_remote_inner(&session_id).await;
// Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed);
result
}
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
// Get current player state and queue context
let (queue_ids, current_index, position_seconds, queue_context) = {
log::info!("[PlaybackMode] Acquiring player controller lock...");
debug!("[PlaybackMode] Acquiring player controller lock...");
let player = self.player_controller.lock().await;
log::info!("[PlaybackMode] Player controller lock acquired");
debug!("[PlaybackMode] Player controller lock acquired");
let queue_arc = player.queue();
let queue = queue_arc.lock().unwrap();
let state = player.state();
let original_index = queue.current_index().unwrap_or(0);
let items = queue.items();
log::info!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
debug!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
// Log each item's jellyfin_id for debugging
for (i, item) in items.iter().enumerate() {
let jf_id = item.jellyfin_id().unwrap_or("NONE");
log::debug!("[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id);
}
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
let position = state.position().unwrap_or(0.0);
let context = queue.context().clone();
log::info!(
"[PlaybackMode] Queue context: {:?}, {} items, current index: {}",
context,
ids.len(),
adjusted_index
);
debug!(
"[PlaybackMode] Extracted {} jellyfin IDs, adjusted_index={}, position={:.2}s",
ids.len(),
adjusted_index,
position
);
(ids, adjusted_index, position, context)
};
// If queue is empty, just switch mode
if queue_ids.is_empty() {
log::info!("[PlaybackMode] Queue is empty, just switching mode");
self.set_mode(PlaybackMode::Remote {
session_id: session_id.to_string(),
});
return Ok(());
}
log::info!(
"[PlaybackMode] Queue has {} items, current index: {}, position: {:.2}s",
queue_ids.len(),
current_index,
position_seconds
);
// Get Jellyfin client for remote transfer
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
let client = {
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
match client_opt.as_ref() {
Some(c) => {
log::info!("[PlaybackMode] Jellyfin client is configured");
debug!("[PlaybackMode] Jellyfin client is configured");
c.clone()
}
None => {
log::error!("[PlaybackMode] Jellyfin client NOT configured!");
error!("[PlaybackMode] Jellyfin client NOT configured!");
return Err("Jellyfin client not configured".to_string());
}
}
};
// Calculate position in ticks
let start_position_ticks = if position_seconds > 0.5 {
Some((position_seconds * 10_000_000.0) as i64)
} else {
None
};
// Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context {
QueueContext::Album { album_id, album_name } => {
log::info!(
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
album_name,
album_id,
queue_ids.len()
);
}
QueueContext::Playlist { playlist_id, playlist_name } => {
log::info!(
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
playlist_name,
playlist_id,
queue_ids.len()
);
}
QueueContext::Custom => {
log::info!(
"[PlaybackMode] Transferring custom queue with {} tracks to remote",
queue_ids.len()
);
}
}
// Always send individual track IDs - Jellyfin's play_on_session expects track IDs,
// not album/playlist container IDs
let expected_item_id = queue_ids
.get(current_index)
.cloned()
.ok_or("Invalid start index")?;
// Send play command to remote session with all track IDs
log::info!(
"[PlaybackMode] Sending play command to remote session: {} ({} tracks, starting at index {}, position: {:.2}s)",
session_id,
queue_ids.len(),
current_index,
position_seconds
);
debug!(
"[PlaybackMode] Calling play_on_session: session={}, tracks={}, index={}, position_ticks={:?}",
session_id,
queue_ids.len(),
current_index,
start_position_ticks
);
// Log first few track IDs for debugging
if queue_ids.len() > 0 {
let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
debug!("[PlaybackMode] First track IDs: {:?}...", preview);
}
client
.play_on_session(
session_id.to_string(),
queue_ids.clone(),
current_index,
start_position_ticks,
)
.await
.map_err(|e| {
log::error!("[PlaybackMode] Failed to send play command: {}", e);
error!("[PlaybackMode] Failed to send play command: {}", e);
format!("Failed to start playback on remote session: {}", e)
})?;
log::info!("[PlaybackMode] Play command sent successfully");
info!("[PlaybackMode] Play command sent successfully to remote session");
// Wait for remote session to load the track (poll with timeout)
log::info!("[PlaybackMode] Waiting for remote session to load track...");
let mut attempts = 0;
let max_attempts = 50; // 5 seconds max (50 * 100ms)
let mut track_loaded = false;
while attempts < max_attempts {
sleep(Duration::from_millis(100)).await;
attempts += 1;
match client.get_session(session_id).await {
Ok(Some(session)) => {
if let Some(now_playing) = &session.now_playing_item {
if now_playing.id.as_deref() == Some(&expected_item_id) {
log::info!(
"[PlaybackMode] Remote session loaded track '{}' after {}ms",
now_playing.name.as_deref().unwrap_or("Unknown"),
attempts * 100
);
track_loaded = true;
break;
}
}
}
Ok(None) => {
log::warn!("[PlaybackMode] Remote session not found while polling");
return Err("Remote session not found".to_string());
}
Err(e) => {
log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e);
// Continue polling - transient errors are OK
}
}
}
if !track_loaded {
log::error!("[PlaybackMode] Timeout waiting for remote session to load track");
return Err("Remote session did not load track in time".to_string());
}
// Stop local playback (queue should remain intact for remote session)
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
{
let player = self.player_controller.lock().await;
// Log queue state BEFORE stop
{
let queue_arc = player.queue();
let queue = queue_arc.lock().unwrap();
info!(
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
queue.items().len(),
queue.current_index()
);
}
player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?;
// Log queue state AFTER stop (should be unchanged)
{
let queue_arc = player.queue();
let queue = queue_arc.lock().unwrap();
info!(
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
queue.items().len(),
queue.current_index()
);
}
}
// Update mode to remote
self.set_mode(PlaybackMode::Remote {
session_id: session_id.to_string(),
});
// Enable remote volume control on Android (intercepts volume buttons)
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
log::info!("[PlaybackMode] Successfully transferred to remote");
Ok(())
}
/// Transfer playback from remote session back to local device
pub async fn transfer_to_local(
&self,
current_item_id: String,
position_ticks: i64,
) -> Result<(), String> {
log::info!("[PlaybackMode] Transferring to local playback");
// Set transferring flag
self.is_transferring.store(true, Ordering::Relaxed);
// Perform the transfer
let result = self
.transfer_to_local_inner(&current_item_id, position_ticks)
.await;
// Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed);
result
}
async fn transfer_to_local_inner(
&self,
current_item_id: &str,
position_ticks: i64,
) -> Result<(), String> {
// Get current remote session info
let session_id = match self.get_mode() {
PlaybackMode::Remote { session_id } => session_id,
_ => return Err("Not in remote playback mode".to_string()),
};
let position_seconds = position_ticks as f64 / 10_000_000.0;
log::info!(
"[PlaybackMode] Transfer to local: session={}, item_id={}, position={:.2}s",
session_id,
current_item_id,
position_seconds
);
// Get Jellyfin client for stopping remote playback
let client = {
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| format!("Failed to lock Jellyfin client: {}", e))?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Stop remote playback
log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id);
match client.send_session_command(session_id.clone(), "Stop").await {
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
Err(e) => {
log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e);
// Don't fail the transfer if we can't stop the remote session
// The user is already playing locally, so this is not critical
}
}
// For now, we'll return an error indicating that the TypeScript side needs to handle
// loading the media item, since we don't have access to the repository here yet.
// This will be improved in Phase 3 when repository is migrated to Rust.
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
// Update mode to local
self.set_mode(PlaybackMode::Local);
// Disable remote volume control on Android (return to system volume)
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::disable_remote_volume() {
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
log::info!("[PlaybackMode] Successfully transferred to local");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test PlaybackMode enum serialization to JSON
///
/// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_playback_mode_serialization() {
let mode_idle = PlaybackMode::Idle;
let json = serde_json::to_string(&mode_idle).unwrap();
assert_eq!(json, r#"{"type":"idle"}"#);
let mode_local = PlaybackMode::Local;
let json = serde_json::to_string(&mode_local).unwrap();
assert_eq!(json, r#"{"type":"local"}"#);
let mode_remote = PlaybackMode::Remote {
session_id: "abc123".to_string(),
};
let json = serde_json::to_string(&mode_remote).unwrap();
assert!(json.contains(r#""type":"remote""#));
assert!(json.contains(r#""session_id":"abc123""#));
}
/// Test PlaybackMode enum deserialization from JSON
///
/// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
#[test]
fn test_playback_mode_deserialization() {
let json = r#"{"type":"idle"}"#;
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
assert_eq!(mode, PlaybackMode::Idle);
let json = r#"{"type":"local"}"#;
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
assert_eq!(mode, PlaybackMode::Local);
let json = r#"{"type":"remote","session_id":"test_session"}"#;
let mode: PlaybackMode = serde_json::from_str(json).unwrap();
assert_eq!(
mode,
PlaybackMode::Remote {
session_id: "test_session".to_string()
}
);
}
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
mod extract_jellyfin_ids_tests {
use crate::player::{MediaItem, MediaSource, MediaType};
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
MediaItem {
id: id.to_string(),
title: format!("Track {}", id),
name: Some(format!("Track {}", id)),
artist: Some("Test Artist".to_string()),
album: Some("Test Album".to_string()),
album_name: Some("Test Album".to_string()),
album_id: Some("album_123".to_string()),
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: format!("http://example.com/{}.mp3", id),
jellyfin_item_id: jellyfin_id.to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
}
}
fn create_test_item_local(id: &str) -> MediaItem {
MediaItem {
id: id.to_string(),
title: format!("Local Track {}", id),
name: Some(format!("Local Track {}", id)),
artist: Some("Test Artist".to_string()),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::DirectUrl {
url: format!("http://example.com/{}.mp3", id),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
}
}
/// Test extracting all Jellyfin track IDs from album
///
/// Verifies that all individual track IDs are extracted, not just the album ID.
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
/// @req-test: DR-003 - Playback mode manager (Jellyfin ID extraction)
/// @req-test: IR-012 - Jellyfin Sessions API for remote playback control
#[test]
fn test_extract_all_jellyfin_ids_from_album() {
// Simulate an album with 5 tracks - all should be extracted
let items: Vec<MediaItem> = (1..=5)
.map(|i| create_test_item_with_jellyfin_id(&format!("track_{}", i), &format!("jf_track_{}", i)))
.collect();
let manager = super::PlaybackModeManager::new(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
);
let result = manager.extract_jellyfin_ids(&items, 2);
assert!(result.is_ok());
let (ids, index) = result.unwrap();
// All 5 track IDs should be extracted (not just the album ID)
assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted");
assert_eq!(ids[0], "jf_track_1");
assert_eq!(ids[1], "jf_track_2");
assert_eq!(ids[2], "jf_track_3");
assert_eq!(ids[3], "jf_track_4");
assert_eq!(ids[4], "jf_track_5");
// Index should point to track 3 (original index 2)
assert_eq!(index, 2, "Current index should be preserved");
}
/// Test extracting Jellyfin IDs filters out local items
///
/// @req-test: UR-010 - Control playback of remote sessions (local filtering)
/// @req-test: DR-003 - Playback mode manager (local item filtering)
#[test]
fn test_extract_filters_local_items() {
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
let items = vec![
create_test_item_with_jellyfin_id("1", "jf_1"),
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_with_jellyfin_id("3", "jf_3"),
create_test_item_with_jellyfin_id("4", "jf_4"),
];
let manager = super::PlaybackModeManager::new(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
);
// Playing track 3 (index 2 in original, should become index 1 after filtering)
let result = manager.extract_jellyfin_ids(&items, 2);
assert!(result.is_ok());
let (ids, index) = result.unwrap();
// Only 3 Jellyfin tracks should be extracted
assert_eq!(ids.len(), 3);
assert_eq!(ids[0], "jf_1");
assert_eq!(ids[1], "jf_3");
assert_eq!(ids[2], "jf_4");
// Index should be adjusted (track 3 is now at position 1)
assert_eq!(index, 1);
}
/// Test extraction fails when current item is local
///
/// @req-test: DR-003 - Playback mode manager (error handling)
/// @req-test: UR-010 - Control playback of remote sessions (validation)
#[test]
fn test_extract_fails_when_current_item_is_local() {
// Current item has no Jellyfin ID - should fail
let items = vec![
create_test_item_with_jellyfin_id("1", "jf_1"),
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_with_jellyfin_id("3", "jf_3"),
];
let manager = super::PlaybackModeManager::new(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
);
// Playing the local track (index 1) should fail
let result = manager.extract_jellyfin_ids(&items, 1);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not from Jellyfin"));
}
/// Test extraction fails on empty queue
///
/// @req-test: DR-003 - Playback mode manager (edge case: empty queue)
#[test]
fn test_extract_empty_queue() {
let items: Vec<MediaItem> = vec![];
let manager = super::PlaybackModeManager::new(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
);
let result = manager.extract_jellyfin_ids(&items, 0);
assert!(result.is_err());
}
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod reporter;
pub mod throttle;
pub mod sync_processor;
pub use reporter::{PlaybackReporter, PlaybackOperation, PlaybackContext};
#[allow(unused_imports)] // Will be used when position updates are hooked
pub use throttle::EventThrottler;
#[allow(unused_imports)] // Will be used when sync processor is integrated
pub use sync_processor::SyncProcessor;
@@ -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"));
}
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -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
}
}
+524
View File
@@ -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");
}
}
}
+315
View File
@@ -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
}
}
+155
View File
@@ -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(),
}
}
}
File diff suppressed because it is too large Load Diff
+548
View File
@@ -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
}
}
+341
View File
@@ -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
);
}
}
+977
View File
@@ -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));
}
}
+357
View File
@@ -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());
}
}
+149
View File
@@ -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);
}
}
+85
View File
@@ -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 { .. })
}
}
+822
View File
@@ -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");
}
}
+173
View File
@@ -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>;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+566
View File
@@ -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");
}
}
+250
View File
@@ -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);
}
}
+183
View File
@@ -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);
}
}
+395
View File
@@ -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);
}
}
+755
View File
@@ -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");
}
}
+15
View File
@@ -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>>,
}
+640
View File
@@ -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);
"#;
+503
View File
@@ -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);
}
}
+41
View File
@@ -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"));
}
}
+121
View File
@@ -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();
}
}
+216
View File
@@ -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
}
}
+1
View File
@@ -0,0 +1 @@
pub mod conversions;
+35
View File
@@ -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"
]
}
}