feat: add local files provider with indexing and rescan functionality
- Implemented LocalFilesProvider to manage local video files. - Added LocalIndex for in-memory and SQLite-backed indexing of video files. - Introduced scanning functionality to detect video files and extract metadata. - Added API endpoints for listing collections, genres, and series based on provider capabilities. - Enhanced existing routes to check for provider capabilities before processing requests. - Updated frontend to utilize provider capabilities for conditional rendering of UI elements. - Implemented rescan functionality to refresh the local files index. - Added database migration for local files index schema.
This commit is contained in:
164
k-tv-backend/infra/src/local_files/scanner.rs
Normal file
164
k-tv-backend/infra/src/local_files/scanner.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
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;
|
||||
}
|
||||
// Must start with 19 or 20.
|
||||
let prefix = chars[i] as u8 * 10 + chars[i + 1] as u8 - b'0' * 11;
|
||||
// Simpler: just 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();
|
||||
let _ = prefix;
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user