many changes
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
//! Device identification commands
|
||||
//!
|
||||
//! Handles persistent device ID generation and retrieval for Jellyfin server communication.
|
||||
//! TRACES: UR-009 | DR-011
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::info;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
/// Get or create the device ID.
|
||||
/// Device ID is a UUID v4 that persists across app restarts.
|
||||
/// On first call, generates and stores a new UUID.
|
||||
/// On subsequent calls, retrieves the stored UUID.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(String)` - The device ID (UUID v4)
|
||||
/// - `Err(String)` - If database operation fails
|
||||
///
|
||||
/// TRACES: UR-009 | DR-011
|
||||
#[tauri::command]
|
||||
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// Try to get existing device ID from database
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String("device_id".to_string())],
|
||||
);
|
||||
|
||||
let existing_id: Option<String> = db_service
|
||||
.query_one(query, |row| row.get(0))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some(device_id) = existing_id {
|
||||
info!("[Device] Retrieved existing device ID");
|
||||
return Ok(device_id);
|
||||
}
|
||||
|
||||
// Generate new device ID
|
||||
let device_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Store it in database
|
||||
let insert_query = Query::with_params(
|
||||
"INSERT INTO app_settings (key, value) VALUES (?, ?)",
|
||||
vec![
|
||||
QueryParam::String("device_id".to_string()),
|
||||
QueryParam::String(device_id.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
db_service
|
||||
.execute(insert_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
info!("[Device] Generated and stored new device ID");
|
||||
Ok(device_id)
|
||||
}
|
||||
|
||||
/// Set the device ID (primarily for testing or recovery).
|
||||
/// Overwrites any existing device ID.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `device_id` - The device ID to store (should be UUID v4 format)
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` - If device ID was stored successfully
|
||||
/// - `Err(String)` - If database operation fails
|
||||
///
|
||||
/// TRACES: UR-009 | DR-011
|
||||
#[tauri::command]
|
||||
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> 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(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
|
||||
vec![
|
||||
QueryParam::String("device_id".to_string()),
|
||||
QueryParam::String(device_id),
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
|
||||
info!("[Device] Device ID set");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_device_id_is_valid_uuid() {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
// Should parse as UUID
|
||||
let parsed = Uuid::parse_str(&id);
|
||||
assert!(parsed.is_ok(), "Device ID should be a valid UUID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_id_format() {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (36 chars with hyphens)
|
||||
assert_eq!(id.len(), 36, "Device ID should be 36 characters");
|
||||
assert!(id.contains('-'), "Device ID should contain hyphens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_ids_are_unique() {
|
||||
let id1 = Uuid::new_v4().to_string();
|
||||
let id2 = Uuid::new_v4().to_string();
|
||||
assert_ne!(id1, id2, "Generated device IDs should be unique");
|
||||
}
|
||||
}
|
||||
@@ -1530,6 +1530,7 @@ pub fn get_album_affinity_status(
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Tauri commands exposed to frontend
|
||||
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
|
||||
// DR-015, DR-017, DR-021, DR-028
|
||||
pub mod auth;
|
||||
pub mod connectivity;
|
||||
pub mod conversions;
|
||||
pub mod device;
|
||||
pub mod download;
|
||||
pub mod offline;
|
||||
pub mod playback_mode;
|
||||
@@ -14,6 +18,7 @@ pub mod sync;
|
||||
pub use auth::*;
|
||||
pub use connectivity::*;
|
||||
pub use conversions::*;
|
||||
pub use device::*;
|
||||
pub use download::*;
|
||||
pub use offline::*;
|
||||
pub use playback_mode::*;
|
||||
|
||||
@@ -130,6 +130,7 @@ pub async fn offline_search(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// TRACES: UR-002, UR-011 | DR-017 | UT-044
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -367,6 +367,33 @@ pub fn repository_get_image_url(
|
||||
Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
|
||||
}
|
||||
|
||||
/// Get subtitle URL for a media item
|
||||
#[tauri::command]
|
||||
pub fn repository_get_subtitle_url(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
media_source_id: String,
|
||||
stream_index: i32,
|
||||
format: String,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo.as_ref().get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
|
||||
}
|
||||
|
||||
/// Get video download URL with quality preset
|
||||
#[tauri::command]
|
||||
pub fn repository_get_video_download_url(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
quality: String,
|
||||
media_source_id: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
Ok(repo.as_ref().get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
||||
}
|
||||
|
||||
/// Mark an item as favorite
|
||||
#[tauri::command]
|
||||
pub async fn repository_mark_favorite(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
||||
//! that need to be synced to the Jellyfin server when connectivity is restored.
|
||||
//! TRACES: UR-002, UR-017, UR-025 | DR-014
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -65,6 +65,8 @@ use 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,
|
||||
// Device commands
|
||||
device_get_id, device_set_id,
|
||||
// Connectivity commands
|
||||
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
|
||||
connectivity_start_monitoring, connectivity_stop_monitoring,
|
||||
@@ -642,6 +644,9 @@ pub fn run() {
|
||||
auth_start_verification,
|
||||
auth_stop_verification,
|
||||
auth_reauthenticate,
|
||||
// Device commands
|
||||
device_get_id,
|
||||
device_set_id,
|
||||
// Connectivity commands
|
||||
connectivity_check_server,
|
||||
connectivity_set_server_url,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Autoplay decision logic
|
||||
// TRACES: UR-023, UR-026 | DR-047, DR-048, DR-029
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::repository::types::MediaItem;
|
||||
|
||||
|
||||
@@ -232,14 +232,13 @@ impl PlayerBackend for NullBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
|
||||
#[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
|
||||
/// TRACES: UR-016 | DR-004 | UT-026
|
||||
#[test]
|
||||
fn test_null_backend_volume_default() {
|
||||
let backend = NullBackend::new();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Player module - Complete playback control system
|
||||
// TRACES: UR-003, UR-004, UR-005, UR-019, UR-023, UR-026 |
|
||||
// IR-003, IR-004, IR-006, IR-008 |
|
||||
// DR-001, DR-004, DR-005, DR-009, DR-028, DR-029, DR-047
|
||||
pub mod autoplay;
|
||||
pub mod backend;
|
||||
pub mod events;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
/// - Tokio runtime panics when spawning async tasks from std::thread
|
||||
/// - Position update thread failures
|
||||
/// - Event emission issues
|
||||
///
|
||||
/// TRACES: UR-003, UR-004 | IR-003 | IT-003, IT-004
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -505,6 +505,7 @@ pub enum AddPosition {
|
||||
End,
|
||||
}
|
||||
|
||||
// TRACES: UR-005, UR-015 | DR-005 | UT-003, UT-004, UT-005
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -78,6 +78,7 @@ impl SleepTimerState {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: UR-026 | DR-029 | UT-012
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -326,6 +326,27 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.get_image_url(item_id, image_type, options)
|
||||
}
|
||||
|
||||
fn get_subtitle_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: &str,
|
||||
stream_index: i32,
|
||||
format: &str,
|
||||
) -> String {
|
||||
// Always use online URL for subtitles
|
||||
self.online.get_subtitle_url(item_id, media_source_id, stream_index, format)
|
||||
}
|
||||
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
) -> String {
|
||||
// Always use online URL for downloads
|
||||
self.online.get_video_download_url(item_id, quality, media_source_id)
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
// Write operations go directly to server
|
||||
self.online.mark_favorite(item_id).await
|
||||
@@ -497,6 +518,25 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_subtitle_url(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_media_source_id: &str,
|
||||
_stream_index: i32,
|
||||
_format: &str,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -603,6 +643,25 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_subtitle_url(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_media_source_id: &str,
|
||||
_stream_index: i32,
|
||||
_format: &str,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
) -> String {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -146,6 +146,23 @@ pub trait MediaRepository: Send + Sync {
|
||||
options: Option<ImageOptions>,
|
||||
) -> String;
|
||||
|
||||
/// Get subtitle URL (synchronous - just constructs URL)
|
||||
fn get_subtitle_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: &str,
|
||||
stream_index: i32,
|
||||
format: &str,
|
||||
) -> String;
|
||||
|
||||
/// Get video download URL (synchronous - just constructs URL)
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
) -> String;
|
||||
|
||||
/// Mark item as favorite
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||
|
||||
|
||||
@@ -918,6 +918,27 @@ impl MediaRepository for OfflineRepository {
|
||||
format!("offline://{}/{}", item_id, type_str)
|
||||
}
|
||||
|
||||
fn get_subtitle_url(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_media_source_id: &str,
|
||||
_stream_index: i32,
|
||||
_format: &str,
|
||||
) -> String {
|
||||
// Subtitles not available offline
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_quality: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
) -> String {
|
||||
// Cannot download while offline
|
||||
String::new()
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||
// Cannot update server while offline
|
||||
Err(RepoError::Offline)
|
||||
|
||||
@@ -562,11 +562,15 @@ impl MediaRepository for OnlineRepository {
|
||||
let mut ungrouped = Vec::new();
|
||||
|
||||
for item in items {
|
||||
if let Some(album_id) = &item.album_id {
|
||||
debug!("[get_recently_played_audio] Grouping item '{}' into album '{}'", item.name, album_id);
|
||||
album_map.entry(album_id.clone()).or_insert_with(Vec::new).push(item);
|
||||
// Use album_id if available, fall back to album_name for grouping
|
||||
let group_key = item.album_id.clone()
|
||||
.or_else(|| item.album_name.clone());
|
||||
|
||||
if let Some(key) = group_key {
|
||||
debug!("[get_recently_played_audio] Grouping item '{}' into album '{}'", item.name, key);
|
||||
album_map.entry(key).or_insert_with(Vec::new).push(item);
|
||||
} else {
|
||||
debug!("[get_recently_played_audio] No album_id for item: '{}'", item.name);
|
||||
debug!("[get_recently_played_audio] No album_id or album_name for item: '{}'", item.name);
|
||||
ungrouped.push(item);
|
||||
}
|
||||
}
|
||||
@@ -1025,6 +1029,50 @@ impl MediaRepository for OnlineRepository {
|
||||
url
|
||||
}
|
||||
|
||||
fn get_subtitle_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: &str,
|
||||
stream_index: i32,
|
||||
format: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}/Videos/{}/{}/Subtitles/{}/{}",
|
||||
self.server_url,
|
||||
item_id,
|
||||
media_source_id,
|
||||
stream_index,
|
||||
format
|
||||
)
|
||||
}
|
||||
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
media_source_id: Option<&str>,
|
||||
) -> String {
|
||||
let mut url = format!("{}/Videos/{}/download", self.server_url, item_id);
|
||||
let mut params = vec![format!("api_key={}", self.access_token)];
|
||||
|
||||
// Add quality parameter if not "original"
|
||||
if quality != "original" {
|
||||
params.push(format!("quality={}", quality));
|
||||
}
|
||||
|
||||
// Add media source ID if provided
|
||||
if let Some(source_id) = media_source_id {
|
||||
params.push(format!("mediaSourceId={}", source_id));
|
||||
}
|
||||
|
||||
if !params.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(¶ms.join("&"));
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::api::jellyfin::{
|
||||
GetItemsOptions, ImageType, ImageOptions, SortOrder,
|
||||
};
|
||||
|
||||
/// Mock for testing URL construction without a real server
|
||||
struct MockOnlineRepository {
|
||||
server_url: String,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
impl MockOnlineRepository {
|
||||
fn new(server_url: &str, access_token: &str) -> Self {
|
||||
Self {
|
||||
server_url: server_url.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test helper: construct image URL similar to backend
|
||||
fn get_image_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
image_type: &str,
|
||||
options: Option<&ImageOptions>,
|
||||
) -> String {
|
||||
let mut url = format!(
|
||||
"{}/Items/{}/Images/{}",
|
||||
self.server_url, item_id, image_type
|
||||
);
|
||||
|
||||
let mut params = vec![("api_key", self.access_token.clone())];
|
||||
|
||||
if let Some(opts) = options {
|
||||
if let Some(max_width) = opts.max_width {
|
||||
params.push(("maxWidth", max_width.to_string()));
|
||||
}
|
||||
if let Some(max_height) = opts.max_height {
|
||||
params.push(("maxHeight", max_height.to_string()));
|
||||
}
|
||||
if let Some(quality) = opts.quality {
|
||||
params.push(("quality", quality.to_string()));
|
||||
}
|
||||
if let Some(tag) = &opts.tag {
|
||||
params.push(("tag", tag.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let query_string = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
if !query_string.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(&query_string);
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
/// Test helper: construct subtitle URL
|
||||
fn get_subtitle_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: &str,
|
||||
stream_index: usize,
|
||||
format: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}/Videos/{}/Subtitles/{}/{}/subtitles.{}?api_key={}",
|
||||
self.server_url,
|
||||
item_id,
|
||||
media_source_id,
|
||||
stream_index,
|
||||
format,
|
||||
self.access_token
|
||||
)
|
||||
}
|
||||
|
||||
/// Test helper: construct video download URL
|
||||
fn get_video_download_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
quality: &str,
|
||||
) -> String {
|
||||
let (max_width, bitrate) = match quality {
|
||||
"1080p" => ("1920", "15000k"),
|
||||
"720p" => ("1280", "8000k"),
|
||||
"480p" => ("854", "3000k"),
|
||||
_ => ("0", ""), // original
|
||||
};
|
||||
|
||||
if quality == "original" {
|
||||
format!("{}/Videos/{}/stream.mp4?api_key={}", self.server_url, item_id, self.access_token)
|
||||
} else {
|
||||
format!(
|
||||
"{}/Videos/{}/stream.mp4?maxWidth={}&videoBitrate={}&api_key={}",
|
||||
self.server_url, item_id, max_width, bitrate, self.access_token
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Image URL Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_image_url_basic() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let url = repo.get_image_url("item123", "Primary", None);
|
||||
|
||||
assert!(url.contains("https://jellyfin.example.com"));
|
||||
assert!(url.contains("/Items/item123/Images/Primary"));
|
||||
assert!(url.contains("api_key=token123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_url_with_max_width() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
let options = ImageOptions {
|
||||
max_width: Some(300),
|
||||
max_height: None,
|
||||
quality: None,
|
||||
tag: None,
|
||||
};
|
||||
|
||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
||||
|
||||
assert!(url.contains("maxWidth=300"));
|
||||
assert!(url.contains("api_key=token123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_url_with_all_options() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
let options = ImageOptions {
|
||||
max_width: Some(1920),
|
||||
max_height: Some(1080),
|
||||
quality: Some(90),
|
||||
tag: Some("abc123".to_string()),
|
||||
};
|
||||
|
||||
let url = repo.get_image_url("item456", "Backdrop", Some(&options));
|
||||
|
||||
assert!(url.contains("/Items/item456/Images/Backdrop"));
|
||||
assert!(url.contains("maxWidth=1920"));
|
||||
assert!(url.contains("maxHeight=1080"));
|
||||
assert!(url.contains("quality=90"));
|
||||
assert!(url.contains("tag=abc123"));
|
||||
assert!(url.contains("api_key=token123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_url_different_image_types() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let image_types = vec!["Primary", "Backdrop", "Logo", "Thumb"];
|
||||
|
||||
for image_type in image_types {
|
||||
let url = repo.get_image_url("item123", image_type, None);
|
||||
assert!(url.contains(&format!("/Images/{}", image_type)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_url_credentials_included_in_backend() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "secret_token");
|
||||
|
||||
let url = repo.get_image_url("item123", "Primary", None);
|
||||
|
||||
// Credentials should be included in backend-generated URL
|
||||
assert!(url.contains("api_key=secret_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_url_proper_encoding() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
let options = ImageOptions {
|
||||
max_width: Some(300),
|
||||
max_height: None,
|
||||
quality: None,
|
||||
tag: Some("tag-with-special-chars".to_string()),
|
||||
};
|
||||
|
||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
||||
|
||||
// URL should be properly formatted
|
||||
assert!(url.contains("?"));
|
||||
assert!(url.contains("&") || !url.contains("&&")); // No double ampersands
|
||||
assert!(!url.ends_with("&")); // No trailing ampersand
|
||||
}
|
||||
|
||||
// ===== Subtitle URL Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_subtitle_url_vtt_format() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let url = repo.get_subtitle_url("item123", "source456", 0, "vtt");
|
||||
|
||||
assert!(url.contains("Videos/item123"));
|
||||
assert!(url.contains("Subtitles/source456/0"));
|
||||
assert!(url.contains("subtitles.vtt"));
|
||||
assert!(url.contains("api_key=token123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subtitle_url_srt_format() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let url = repo.get_subtitle_url("item123", "source456", 1, "srt");
|
||||
|
||||
assert!(url.contains("Subtitles/source456/1"));
|
||||
assert!(url.contains("subtitles.srt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subtitle_url_multiple_streams() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
for stream_index in 0..5 {
|
||||
let url = repo.get_subtitle_url("item123", "source456", stream_index, "vtt");
|
||||
assert!(url.contains(&format!("/{}/subtitles", stream_index)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subtitle_url_different_media_sources() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let media_sources = vec!["src1", "src2", "src3"];
|
||||
|
||||
for media_source_id in media_sources {
|
||||
let url = repo.get_subtitle_url("item123", media_source_id, 0, "vtt");
|
||||
assert!(url.contains(&format!("Subtitles/{}/", media_source_id)));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Video Download URL Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_video_download_url_original_quality() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let url = repo.get_video_download_url("item123", "original");
|
||||
|
||||
assert!(url.contains("Videos/item123/stream.mp4"));
|
||||
assert!(url.contains("api_key=token123"));
|
||||
assert!(!url.contains("maxWidth")); // Original should have no transcoding params
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_download_url_1080p() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let url = repo.get_video_download_url("item123", "1080p");
|
||||
|
||||
assert!(url.contains("maxWidth=1920"));
|
||||
assert!(url.contains("videoBitrate=15000k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_download_url_720p() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let url = repo.get_video_download_url("item123", "720p");
|
||||
|
||||
assert!(url.contains("maxWidth=1280"));
|
||||
assert!(url.contains("videoBitrate=8000k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_download_url_480p() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let url = repo.get_video_download_url("item123", "480p");
|
||||
|
||||
assert!(url.contains("maxWidth=854"));
|
||||
assert!(url.contains("videoBitrate=3000k"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_download_url_quality_presets() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
let qualities = vec!["original", "1080p", "720p", "480p"];
|
||||
|
||||
for quality in qualities {
|
||||
let url = repo.get_video_download_url("item123", quality);
|
||||
assert!(url.contains("Videos/item123/stream.mp4"));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Security Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_credentials_never_exposed_in_frontend() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "super_secret_token");
|
||||
|
||||
let image_url = repo.get_image_url("item123", "Primary", None);
|
||||
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
|
||||
let download_url = repo.get_video_download_url("item123", "720p");
|
||||
|
||||
// These URLs are constructed in BACKEND and returned to frontend
|
||||
// Frontend never receives this token directly
|
||||
assert!(image_url.contains("api_key=super_secret_token"));
|
||||
assert!(subtitle_url.contains("api_key=super_secret_token"));
|
||||
assert!(download_url.contains("api_key=super_secret_token"));
|
||||
|
||||
// In actual implementation, frontend would only get the URL string
|
||||
// Frontend cannot construct its own URLs or extract the token
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_parameter_injection_prevention() {
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
||||
|
||||
// Try to inject parameters through item_id
|
||||
let malicious_id = "item123&extraParam=malicious";
|
||||
let url = repo.get_image_url(malicious_id, "Primary", None);
|
||||
|
||||
// URL should contain the full item_id, backend should handle escaping
|
||||
assert!(url.contains(malicious_id));
|
||||
// Backend should be responsible for proper URL encoding
|
||||
}
|
||||
|
||||
// ===== URL Format Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_image_url_format_correctness() {
|
||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
||||
|
||||
let url = repo.get_image_url("id123", "Primary", None);
|
||||
|
||||
// Should be valid format
|
||||
assert!(url.starts_with("https://server.com"));
|
||||
assert!(url.contains("/Items/id123/Images/Primary"));
|
||||
assert!(url.contains("?api_key="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_properly_separated() {
|
||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
||||
let options = ImageOptions {
|
||||
max_width: Some(300),
|
||||
max_height: Some(200),
|
||||
quality: None,
|
||||
tag: None,
|
||||
};
|
||||
|
||||
let url = repo.get_image_url("id123", "Primary", Some(&options));
|
||||
|
||||
// Should have single ? separator
|
||||
let question_marks = url.matches('?').count();
|
||||
assert_eq!(question_marks, 1);
|
||||
|
||||
// Should have ampersands between params
|
||||
assert!(url.contains("?"));
|
||||
assert!(url.contains("&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_special_characters_in_urls() {
|
||||
let repo = MockOnlineRepository::new("https://server.com", "token_with_special-chars");
|
||||
|
||||
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
|
||||
|
||||
// Should handle special characters in token and id
|
||||
assert!(url.contains("token_with_special-chars"));
|
||||
assert!(url.contains("item-with-special_chars"));
|
||||
}
|
||||
|
||||
// ===== Backend vs Frontend Responsibility Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_backend_owns_url_construction() {
|
||||
// This test documents that URL construction is ONLY in backend
|
||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "secret_token");
|
||||
|
||||
// Backend generates full URL with credentials
|
||||
let url = repo.get_image_url("item123", "Primary", None);
|
||||
|
||||
// URL is complete and ready to use
|
||||
assert!(url.starts_with("https://"));
|
||||
assert!(url.contains("api_key="));
|
||||
|
||||
// Frontend never constructs URLs directly
|
||||
// Frontend only receives pre-constructed URLs from backend
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_includes_all_necessary_parameters() {
|
||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
||||
let options = ImageOptions {
|
||||
max_width: Some(300),
|
||||
max_height: Some(200),
|
||||
quality: Some(90),
|
||||
tag: Some("abc".to_string()),
|
||||
};
|
||||
|
||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
||||
|
||||
// All provided options should be in URL
|
||||
assert!(url.contains("maxWidth=300"));
|
||||
assert!(url.contains("maxHeight=200"));
|
||||
assert!(url.contains("quality=90"));
|
||||
assert!(url.contains("tag=abc"));
|
||||
assert!(url.contains("api_key=token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_parameters_omitted_when_not_provided() {
|
||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
||||
let options = ImageOptions {
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
quality: None,
|
||||
tag: None,
|
||||
};
|
||||
|
||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
||||
|
||||
// Should only have api_key
|
||||
assert!(url.contains("api_key=token"));
|
||||
assert!(!url.contains("maxWidth"));
|
||||
assert!(!url.contains("maxHeight"));
|
||||
assert!(!url.contains("quality"));
|
||||
assert!(!url.contains("tag"));
|
||||
}
|
||||
}
|
||||
@@ -285,6 +285,7 @@ fn convert_params(params: &[QueryParam]) -> Vec<rusqlite::types::Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
// TRACES: UR-002, UR-012 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -156,6 +156,7 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: UR-002, UR-012, UR-019, UR-025 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("012_download_source", MIGRATION_012),
|
||||
("013_downloads_item_status_index", MIGRATION_013),
|
||||
("014_series_audio_preferences", MIGRATION_014),
|
||||
("015_device_id", MIGRATION_015),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -638,3 +639,20 @@ CREATE TABLE IF NOT EXISTS series_audio_preferences (
|
||||
CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
|
||||
ON series_audio_preferences(user_id, series_id);
|
||||
"#;
|
||||
|
||||
/// Migration to add device ID storage
|
||||
/// - Creates app_settings table for app-wide configuration (device ID, etc.)
|
||||
/// - Device ID is generated once and persisted for Jellyfin server identification
|
||||
const MIGRATION_015: &str = r#"
|
||||
-- App-wide settings table for device ID and other app-level configuration
|
||||
-- Device ID is a unique identifier for this app installation
|
||||
-- Required for Jellyfin server communication and session tracking
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index for efficient lookups (though key is already primary key)
|
||||
CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
|
||||
"#;
|
||||
|
||||
Reference in New Issue
Block a user