- strip all comments except WHY workaround notes (3 remain) - remove all #[allow(dead_code)]; fix via _prefix rename - extract named constants: JWT time units, token types, default config values, jellyfin fallback bitrate - DRY: move serialize_enum_as_string, content_type_str, parse_content_type, parse_genres_blob to adapter-common - sqlite+postgres library.rs use shared helpers instead of local copies - sqlite+postgres channel.rs use shared serialize_enum_as_string - remove dead `let _ = ext` in scanner.rs
163 lines
4.4 KiB
Rust
163 lines
4.4 KiB
Rust
use std::path::Path;
|
|
use tokio::process::Command;
|
|
|
|
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "webm", "m4v"];
|
|
const ROOT_COLLECTION_NAME: &str = "__root__";
|
|
const YEAR_DIGITS: usize = 4;
|
|
const MIN_YEAR: u16 = 1900;
|
|
const MAX_YEAR: u16 = 2099;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LocalFileItem {
|
|
pub rel_path: String,
|
|
pub title: String,
|
|
pub duration_secs: u32,
|
|
pub year: Option<u16>,
|
|
pub tags: Vec<String>,
|
|
pub top_dir: String,
|
|
}
|
|
|
|
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());
|
|
match ext {
|
|
Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => {}
|
|
_ => continue,
|
|
};
|
|
|
|
let rel = match path.strip_prefix(root) {
|
|
Ok(r) => r,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
let rel_path: String = rel
|
|
.components()
|
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
|
|
let top_dir = rel
|
|
.components()
|
|
.next()
|
|
.filter(|_| rel.components().count() > 1)
|
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| ROOT_COLLECTION_NAME.to_string());
|
|
|
|
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();
|
|
|
|
let search_str = format!(
|
|
"{} {}",
|
|
stem,
|
|
rel.parent()
|
|
.and_then(|p| p.to_str())
|
|
.unwrap_or("")
|
|
);
|
|
let year = extract_year(&search_str);
|
|
|
|
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
|
|
}
|
|
|
|
fn extract_year(s: &str) -> Option<u16> {
|
|
let chars: Vec<char> = s.chars().collect();
|
|
let n = chars.len();
|
|
if n < YEAR_DIGITS {
|
|
return None;
|
|
}
|
|
for i in 0..=(n - YEAR_DIGITS) {
|
|
if !chars[i..i + YEAR_DIGITS].iter().all(|c| c.is_ascii_digit()) {
|
|
continue;
|
|
}
|
|
let s4: String = chars[i..i + YEAR_DIGITS].iter().collect();
|
|
let num: u16 = s4.parse().ok()?;
|
|
if !(MIN_YEAR..=MAX_YEAR).contains(&num) {
|
|
continue;
|
|
}
|
|
let before_ok = i == 0 || !chars[i - 1].is_ascii_digit();
|
|
let after_ok = i + YEAR_DIGITS >= n || !chars[i + YEAR_DIGITS].is_ascii_digit();
|
|
if before_ok && after_ok {
|
|
return Some(num);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn extract_year_basic() {
|
|
assert_eq!(extract_year("Movie 2024 HD"), Some(2024));
|
|
assert_eq!(extract_year("1999_classic"), Some(1999));
|
|
assert_eq!(extract_year("no year here"), None);
|
|
assert_eq!(extract_year("12345"), None);
|
|
assert_eq!(extract_year("2100"), None);
|
|
assert_eq!(extract_year("1900"), Some(1900));
|
|
assert_eq!(extract_year("2099"), Some(2099));
|
|
}
|
|
}
|