Files
k-tv/crates/adapters/local-files/src/scanner.rs
Gabriel Kaszewski 25b33b6a0e cleanup: strip comments, extract constants, DRY shared helpers across adapters + infra-wiring
- 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
2026-07-12 04:21:21 +02:00

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));
}
}