- Introduced IProviderRegistry to manage multiple media providers. - Updated AppState to use provider_registry instead of a single media_provider. - Refactored library routes to support provider-specific queries for collections, series, genres, and items. - Enhanced ProgrammingBlock to include provider_id for algorithmic and manual content types. - Modified frontend components to allow selection of providers and updated API calls to include provider parameters. - Adjusted hooks and types to accommodate provider-specific functionality.
162 lines
4.9 KiB
Rust
162 lines
4.9 KiB
Rust
use std::path::Path;
|
||
use tokio::process::Command;
|
||
|
||
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "webm", "m4v"];
|
||
|
||
/// In-memory representation of a scanned local video file.
|
||
#[derive(Debug, Clone)]
|
||
pub struct LocalFileItem {
|
||
/// Relative path from root, with forward slashes (used as the stable ID source).
|
||
pub rel_path: String,
|
||
pub title: String,
|
||
pub duration_secs: u32,
|
||
pub year: Option<u16>,
|
||
/// Ancestor directory names between root and file (excluding root itself).
|
||
pub tags: Vec<String>,
|
||
/// First path component under root (used as collection id/name).
|
||
pub top_dir: String,
|
||
}
|
||
|
||
/// Walk `root` and return all recognised video files with metadata.
|
||
///
|
||
/// ffprobe is called for each file to determine duration. Files that cannot be
|
||
/// probed are included with `duration_secs = 0` so they still appear in the index.
|
||
pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
|
||
let mut items = Vec::new();
|
||
|
||
let walker = walkdir::WalkDir::new(root).follow_links(true);
|
||
for entry in walker.into_iter().filter_map(|e| e.ok()) {
|
||
if !entry.file_type().is_file() {
|
||
continue;
|
||
}
|
||
let path = entry.path();
|
||
let ext = path
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.map(|e| e.to_lowercase());
|
||
let ext = match ext {
|
||
Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => e.clone(),
|
||
_ => continue,
|
||
};
|
||
let _ = ext; // extension validated, not needed further
|
||
|
||
let rel = match path.strip_prefix(root) {
|
||
Ok(r) => r,
|
||
Err(_) => continue,
|
||
};
|
||
|
||
// Normalise to forward-slash string for cross-platform stability.
|
||
let rel_path: String = rel
|
||
.components()
|
||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||
.collect::<Vec<_>>()
|
||
.join("/");
|
||
|
||
// Top-level directory under root.
|
||
let top_dir = rel
|
||
.components()
|
||
.next()
|
||
.filter(|_| rel.components().count() > 1) // skip if file is at root level
|
||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| "__root__".to_string());
|
||
|
||
// Title: stem with separator chars replaced by spaces.
|
||
let stem = path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
let title = stem.replace(['_', '-', '.'], " ");
|
||
let title = title.trim().to_string();
|
||
|
||
// Year: first 4-digit number starting with 19xx or 20xx in filename or parent dirs.
|
||
let search_str = format!(
|
||
"{} {}",
|
||
stem,
|
||
rel.parent()
|
||
.and_then(|p| p.to_str())
|
||
.unwrap_or("")
|
||
);
|
||
let year = extract_year(&search_str);
|
||
|
||
// Tags: ancestor directory components between root and the file.
|
||
let tags: Vec<String> = rel
|
||
.parent()
|
||
.into_iter()
|
||
.flat_map(|p| p.components())
|
||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||
.filter(|s| !s.is_empty())
|
||
.collect();
|
||
|
||
let duration_secs = get_duration(path).await.unwrap_or(0);
|
||
|
||
items.push(LocalFileItem {
|
||
rel_path,
|
||
title,
|
||
duration_secs,
|
||
year,
|
||
tags,
|
||
top_dir,
|
||
});
|
||
}
|
||
|
||
items
|
||
}
|
||
|
||
/// Extract the first plausible 4-digit year (1900–2099) from `s`.
|
||
fn extract_year(s: &str) -> Option<u16> {
|
||
let chars: Vec<char> = s.chars().collect();
|
||
let n = chars.len();
|
||
if n < 4 {
|
||
return None;
|
||
}
|
||
for i in 0..=(n - 4) {
|
||
// All four chars must be ASCII digits.
|
||
if !chars[i..i + 4].iter().all(|c| c.is_ascii_digit()) {
|
||
continue;
|
||
}
|
||
// Parse and range-check.
|
||
let s4: String = chars[i..i + 4].iter().collect();
|
||
let num: u16 = s4.parse().ok()?;
|
||
if !(1900..=2099).contains(&num) {
|
||
continue;
|
||
}
|
||
// Word-boundary: char before and after must not be digits.
|
||
let before_ok = i == 0 || !chars[i - 1].is_ascii_digit();
|
||
let after_ok = i + 4 >= n || !chars[i + 4].is_ascii_digit();
|
||
if before_ok && after_ok {
|
||
return Some(num);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Run ffprobe to get the duration of `path` in whole seconds.
|
||
async fn get_duration(path: &Path) -> Option<u32> {
|
||
#[derive(serde::Deserialize)]
|
||
struct Fmt {
|
||
duration: Option<String>,
|
||
}
|
||
#[derive(serde::Deserialize)]
|
||
struct Out {
|
||
format: Fmt,
|
||
}
|
||
|
||
let output = Command::new("ffprobe")
|
||
.args([
|
||
"-v",
|
||
"quiet",
|
||
"-print_format",
|
||
"json",
|
||
"-show_format",
|
||
path.to_str()?,
|
||
])
|
||
.output()
|
||
.await
|
||
.ok()?;
|
||
|
||
let parsed: Out = serde_json::from_slice(&output.stdout).ok()?;
|
||
let dur: f64 = parsed.format.duration?.parse().ok()?;
|
||
Some(dur as u32)
|
||
}
|