many changes
Traceability Validation / Check Requirement Traces (push) Failing after 1m18s
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled

This commit is contained in:
2026-02-14 00:09:47 +01:00
parent 6d1c618a3a
commit e3797f32ca
74 changed files with 6718 additions and 771 deletions
+59
View File
@@ -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!()
}
+17
View File
@@ -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>;
+21
View File
@@ -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)
+52 -4
View File
@@ -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(&params.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"));
}
}