Skip to main content

cloudillo_file/
descriptor.rs

1//! File descriptor generation and parsing.
2//!
3//! Supports two descriptor formats:
4//! - d1~ (legacy): variant separator is `,`, ID separator is `~`
5//! - d2, (new): variant separator is `;`, ID separator is `,`
6//!
7//! Format examples:
8//! d1~tn:b1~abc123:f=webp:s=2048:r=128x128,sd:b1~def456:f=webp:s=10240:r=720x720
9//! d2,vis.tn:b1,abc123:f=webp:s=2048:r=128x128;vis.sd:b1,def456:f=webp:s=10240:r=720x720:dur=120.5:br=5000
10
11use async_trait::async_trait;
12use itertools::Itertools;
13use serde::{Deserialize, Serialize};
14use std::{fmt::Debug, sync::Arc};
15
16use crate::handler::GetFileVariantSelector;
17use crate::prelude::*;
18use crate::variant::{Variant, VariantClass};
19use cloudillo_core::scheduler::{Task, TaskId};
20use cloudillo_types::hasher::Hasher;
21use cloudillo_types::meta_adapter;
22use cloudillo_types::types::TnId;
23
24/// Descriptor format version
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum DescriptorVersion {
27	/// Legacy format: d1~, variant separator `,`, ID separator `~`
28	V1,
29	/// New format: d2,, variant separator `;`, ID separator `,`
30	V2,
31}
32
33impl DescriptorVersion {
34	/// Get the prefix for this version
35	pub fn prefix(&self) -> &'static str {
36		match self {
37			Self::V1 => "d1~",
38			Self::V2 => "d2,",
39		}
40	}
41
42	/// Get the variant separator for this version
43	pub fn variant_separator(&self) -> char {
44		match self {
45			Self::V1 => ',',
46			Self::V2 => ';',
47		}
48	}
49
50	/// Get the ID separator for this version (prefix~hash or prefix,hash)
51	pub fn id_separator(&self) -> char {
52		match self {
53			Self::V1 => '~',
54			Self::V2 => ',',
55		}
56	}
57}
58
59/// Generate file descriptor in the new d2 format
60pub fn get_file_descriptor<S: AsRef<str> + Debug + Eq>(
61	variants: &[meta_adapter::FileVariant<S>],
62) -> String {
63	get_file_descriptor_versioned(variants, DescriptorVersion::V2)
64}
65
66/// Generate file descriptor with explicit version
67pub fn get_file_descriptor_versioned<S: AsRef<str> + Debug + Eq>(
68	variants: &[meta_adapter::FileVariant<S>],
69	version: DescriptorVersion,
70) -> String {
71	let sep = version.variant_separator();
72	let id_sep = version.id_separator();
73
74	// Note: variant_id keeps its original format (b1~hash) - we don't change the ~ separator
75	// The d2, prefix differentiates descriptors from hashed IDs
76	let _ = id_sep; // Unused but kept for API consistency
77
78	version.prefix().to_owned()
79		+ &variants
80			.iter()
81			.map(|v| {
82				let mut parts = format!(
83					"{}:{}:f={}:s={}:r={}x{}",
84					v.variant.as_ref(),
85					v.variant_id.as_ref(),
86					v.format.as_ref(),
87					v.size,
88					v.resolution.0,
89					v.resolution.1
90				);
91
92				// Add optional properties (skip zero values - semantically equivalent to unset)
93				if let Some(dur) = v.duration.filter(|&d| d != 0.0) {
94					parts.push_str(&format!(":dur={}", dur));
95				}
96				if let Some(br) = v.bitrate.filter(|&b| b != 0) {
97					parts.push_str(&format!(":br={}", br));
98				}
99				if let Some(pg) = v.page_count.filter(|&p| p != 0) {
100					parts.push_str(&format!(":pg={}", pg));
101				}
102
103				parts
104			})
105			.join(&sep.to_string())
106}
107
108/// Parse a single variant entry from descriptor
109fn parse_variant_entry(
110	entry: &str,
111	_id_separator: char,
112) -> ClResult<meta_adapter::FileVariant<&str>> {
113	let v_vec: Vec<&str> = entry.split(':').collect();
114	if v_vec.len() < 2 {
115		return Err(Error::Parse);
116	}
117
118	let variant = v_vec[0];
119	let variant_id = v_vec[1];
120
121	let mut resolution: Option<(u32, u32)> = None;
122	let mut format: Option<&str> = Some("avif");
123	let mut size: Option<u64> = None;
124	let mut duration: Option<f64> = None;
125	let mut bitrate: Option<u32> = None;
126	let mut page_count: Option<u32> = None;
127
128	for prop in v_vec[2..].iter() {
129		if let Some(val) = prop.strip_prefix("f=") {
130			format = Some(val);
131		} else if let Some(val) = prop.strip_prefix("s=") {
132			size = Some(val.parse().map_err(|_| Error::Parse)?);
133		} else if let Some(val) = prop.strip_prefix("r=") {
134			let res_str: (&str, &str) = val.split('x').collect_tuple().ok_or(Error::Parse)?;
135			resolution = Some((res_str.0.parse()?, res_str.1.parse()?));
136		} else if let Some(val) = prop.strip_prefix("dur=") {
137			duration = Some(val.parse().map_err(|_| Error::Parse)?);
138		} else if let Some(val) = prop.strip_prefix("br=") {
139			bitrate = Some(val.parse().map_err(|_| Error::Parse)?);
140		} else if let Some(val) = prop.strip_prefix("pg=") {
141			page_count = Some(val.parse().map_err(|_| Error::Parse)?);
142		}
143		// Ignore unknown properties for forward compatibility
144	}
145
146	if let (Some(resolution), Some(format), Some(size)) = (resolution, format, size) {
147		Ok(meta_adapter::FileVariant {
148			variant,
149			variant_id,
150			resolution,
151			format,
152			size,
153			available: false,
154			duration,
155			bitrate,
156			page_count,
157		})
158	} else {
159		error!(
160			"Invalid variant entry - resolution: {:?}, format: {:?}, size: {:?}",
161			resolution, format, size
162		);
163		Err(Error::Parse)
164	}
165}
166
167/// Parse file descriptor (supports both d1 and d2 formats)
168pub fn parse_file_descriptor(descriptor: &str) -> ClResult<Vec<meta_adapter::FileVariant<&str>>> {
169	if let Some(body) = descriptor.strip_prefix("d2,") {
170		// New format: d2, with ; variant separator and , ID separator
171		body.split(';')
172			.filter(|s| !s.is_empty())
173			.map(|entry| parse_variant_entry(entry, ','))
174			.collect()
175	} else if let Some(body) = descriptor.strip_prefix("d1~") {
176		// Legacy format: d1~ with , variant separator and ~ ID separator
177		body.split(',')
178			.filter(|s| !s.is_empty())
179			.map(|entry| parse_variant_entry(entry, '~'))
180			.collect()
181	} else {
182		Err(Error::Parse)
183	}
184}
185
186/// Normalize variant name for comparison
187/// Handles both legacy (sd) and new (vis.sd) formats
188fn normalize_variant_name(name: &str) -> &str {
189	// If it's a two-level name, extract just the quality part for legacy comparison
190	if let Some((_class, quality)) = name.split_once('.') {
191		quality
192	} else {
193		name
194	}
195}
196
197/// Check if a variant matches the requested variant (supports both formats)
198fn variant_matches(variant: &str, requested: &str) -> bool {
199	// Direct match
200	if variant == requested {
201		return true;
202	}
203
204	// Legacy format match: "sd" matches "vis.sd"
205	if let Some(parsed) = Variant::parse(variant) {
206		if parsed.quality.as_str() == requested {
207			return true;
208		}
209	}
210
211	// New format match: "vis.sd" matches when requesting "sd"
212	if normalize_variant_name(variant) == requested {
213		return true;
214	}
215
216	false
217}
218
219/// Find variant by name in a list (supports both legacy and new formats)
220fn find_variant<'a, S: AsRef<str> + Debug>(
221	variants: &[&'a meta_adapter::FileVariant<S>],
222	name: &str,
223) -> Option<&'a meta_adapter::FileVariant<S>> {
224	variants.iter().find(|v| variant_matches(v.variant.as_ref(), name)).copied()
225}
226
227/// Choose best variant with optional class filter
228pub fn get_best_file_variant<'a, S: AsRef<str> + Debug + Eq>(
229	variants: &'a [meta_adapter::FileVariant<S>],
230	selector: &'_ GetFileVariantSelector,
231) -> ClResult<&'a meta_adapter::FileVariant<S>> {
232	debug!("get_best_file_variant: {:?}", selector);
233
234	// Parse the requested variant to see if it has a class prefix
235	let (requested_class, requested_quality) = if let Some(ref variant_str) = selector.variant {
236		if let Some(parsed) = Variant::parse(variant_str) {
237			(Some(parsed.class), parsed.quality.as_str())
238		} else {
239			(None, variant_str.as_str())
240		}
241	} else {
242		(None, "tn") // Default to thumbnail
243	};
244
245	// Filter variants by class if specified
246	let class_filtered: Vec<_> = if let Some(class) = requested_class {
247		variants
248			.iter()
249			.filter(|v| {
250				if let Some(parsed) = Variant::parse(v.variant.as_ref()) {
251					parsed.class == class
252				} else {
253					// Legacy variants are assumed to be Visual
254					class == VariantClass::Visual
255				}
256			})
257			.collect()
258	} else {
259		variants.iter().collect()
260	};
261
262	let best = match requested_quality {
263		"tn" => find_variant(&class_filtered, "tn")
264			.or_else(|| find_variant(&class_filtered, "pf"))
265			.ok_or(Error::NotFound),
266		"sd" => find_variant(&class_filtered, "sd")
267			.or_else(|| find_variant(&class_filtered, "md"))
268			.or_else(|| find_variant(&class_filtered, "tn"))
269			.or_else(|| find_variant(&class_filtered, "pf"))
270			.ok_or(Error::NotFound),
271		"md" => find_variant(&class_filtered, "md")
272			.or_else(|| find_variant(&class_filtered, "sd"))
273			.or_else(|| find_variant(&class_filtered, "tn"))
274			.ok_or(Error::NotFound),
275		"hd" => find_variant(&class_filtered, "hd")
276			.or_else(|| find_variant(&class_filtered, "md"))
277			.or_else(|| find_variant(&class_filtered, "sd"))
278			.or_else(|| find_variant(&class_filtered, "tn"))
279			.ok_or(Error::NotFound),
280		"xd" => find_variant(&class_filtered, "xd")
281			.or_else(|| find_variant(&class_filtered, "hd"))
282			.or_else(|| find_variant(&class_filtered, "md"))
283			.or_else(|| find_variant(&class_filtered, "sd"))
284			.or_else(|| find_variant(&class_filtered, "tn"))
285			.ok_or(Error::NotFound),
286		"pf" => find_variant(&class_filtered, "pf")
287			.or_else(|| find_variant(&class_filtered, "tn"))
288			.ok_or(Error::NotFound),
289		"orig" => find_variant(&class_filtered, "orig")
290			.or_else(|| find_variant(&class_filtered, "xd"))
291			.or_else(|| find_variant(&class_filtered, "hd"))
292			.ok_or(Error::NotFound),
293		_ => Err(Error::NotFound),
294	};
295
296	debug!("best variant: {:?}", best);
297	best
298}
299
300/// File ID generator Task
301#[derive(Debug, Serialize, Deserialize)]
302pub struct FileIdGeneratorTask {
303	tn_id: TnId,
304	f_id: u64,
305}
306
307impl FileIdGeneratorTask {
308	pub fn new(tn_id: TnId, f_id: u64) -> Arc<Self> {
309		Arc::new(Self { tn_id, f_id })
310	}
311}
312
313#[async_trait]
314impl Task<App> for FileIdGeneratorTask {
315	fn kind() -> &'static str {
316		"file.id-generate"
317	}
318	fn kind_of(&self) -> &'static str {
319		Self::kind()
320	}
321
322	fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
323		let (tn_id, f_id) = ctx
324			.split(',')
325			.collect_tuple()
326			.ok_or(Error::Internal("invalid FileIdGenerator context format".into()))?;
327		let task = FileIdGeneratorTask::new(TnId(tn_id.parse()?), f_id.parse()?);
328		Ok(task)
329	}
330
331	fn serialize(&self) -> String {
332		format!("{},{}", self.tn_id, self.f_id)
333	}
334
335	async fn run(&self, app: &App) -> ClResult<()> {
336		info!("Running task file.id-generate {}", self.f_id);
337		let mut variants = app
338			.meta_adapter
339			.list_file_variants(self.tn_id, meta_adapter::FileId::FId(self.f_id))
340			.await?;
341		variants.sort();
342		let descriptor = get_file_descriptor(&variants);
343
344		let mut hasher = Hasher::new();
345		hasher.update(descriptor.as_bytes());
346		let file_id = hasher.finalize("f");
347
348		// Finalize the file - sets file_id and transitions status from 'P' to 'A' atomically
349		app.meta_adapter.finalize_file(self.tn_id, self.f_id, &file_id).await?;
350
351		// Broadcast FILE_ID_GENERATED event to all connections on this tenant
352		// Frontend clients will filter based on whether they're tracking this temp ID
353		let msg = cloudillo_core::ws_broadcast::BroadcastMessage::new(
354			"FILE_ID_GENERATED",
355			serde_json::json!({
356				"tempId": format!("@{}", self.f_id),
357				"fileId": file_id
358			}),
359			"system",
360		);
361		let delivered = app.broadcast.send_to_tenant(self.tn_id, msg).await;
362		debug!("FILE_ID_GENERATED broadcast delivered to {} connections", delivered);
363
364		info!("Finished task file.id-generate {} → {}", descriptor, file_id);
365		Ok(())
366	}
367}
368
369#[cfg(test)]
370mod tests {
371	use super::*;
372
373	#[test]
374	fn test_parse_d1_descriptor() {
375		let desc = "d1~tn:b1~abc123:f=webp:s=2048:r=128x128,sd:b1~def456:f=webp:s=10240:r=720x720";
376		let variants = parse_file_descriptor(desc).unwrap();
377
378		assert_eq!(variants.len(), 2);
379		assert_eq!(variants[0].variant, "tn");
380		assert_eq!(variants[0].variant_id, "b1~abc123");
381		assert_eq!(variants[0].format, "webp");
382		assert_eq!(variants[0].size, 2048);
383		assert_eq!(variants[0].resolution, (128, 128));
384
385		assert_eq!(variants[1].variant, "sd");
386		assert_eq!(variants[1].variant_id, "b1~def456");
387	}
388
389	#[test]
390	fn test_parse_d2_descriptor() {
391		// Note: variant_ids keep the ~ separator (b1~hash), only descriptor prefix uses comma (d2,)
392		let desc = "d2,vis.tn:b1~abc123:f=webp:s=2048:r=128x128;vis.sd:b1~def456:f=webp:s=10240:r=720x720:dur=120.5:br=5000";
393		let variants = parse_file_descriptor(desc).unwrap();
394
395		assert_eq!(variants.len(), 2);
396		assert_eq!(variants[0].variant, "vis.tn");
397		assert_eq!(variants[0].variant_id, "b1~abc123");
398		assert_eq!(variants[0].format, "webp");
399		assert_eq!(variants[0].size, 2048);
400		assert_eq!(variants[0].resolution, (128, 128));
401		assert_eq!(variants[0].duration, None);
402
403		assert_eq!(variants[1].variant, "vis.sd");
404		assert_eq!(variants[1].variant_id, "b1~def456");
405		assert_eq!(variants[1].duration, Some(120.5));
406		assert_eq!(variants[1].bitrate, Some(5000));
407	}
408
409	#[test]
410	fn test_generate_d2_descriptor() {
411		let variants = vec![
412			meta_adapter::FileVariant {
413				variant: "vis.tn",
414				variant_id: "b1~abc123",
415				format: "webp",
416				size: 2048,
417				resolution: (128, 128),
418				available: true,
419				duration: None,
420				bitrate: None,
421				page_count: None,
422			},
423			meta_adapter::FileVariant {
424				variant: "vid.hd",
425				variant_id: "b1~def456",
426				format: "mp4",
427				size: 51200,
428				resolution: (1920, 1080),
429				available: true,
430				duration: Some(120.5),
431				bitrate: Some(5000),
432				page_count: None,
433			},
434		];
435
436		let desc = get_file_descriptor(&variants);
437		assert!(desc.starts_with("d2,"));
438		// variant_ids keep their ~ separator
439		assert!(desc.contains("vis.tn:b1~abc123"));
440		assert!(desc.contains("vid.hd:b1~def456"));
441		assert!(desc.contains(":dur=120.5"));
442		assert!(desc.contains(":br=5000"));
443		// Variants are separated by ;
444		assert!(desc.contains(";vid.hd"));
445	}
446
447	#[test]
448	fn test_variant_matches() {
449		// Direct match
450		assert!(variant_matches("sd", "sd"));
451		assert!(variant_matches("vis.sd", "vis.sd"));
452
453		// Legacy to new format match
454		assert!(variant_matches("vis.sd", "sd"));
455		assert!(variant_matches("vid.hd", "hd"));
456
457		// No match
458		assert!(!variant_matches("vis.sd", "hd"));
459		assert!(!variant_matches("sd", "hd"));
460	}
461}
462
463// vim: ts=4