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
This commit is contained in:
2026-07-12 04:21:21 +02:00
parent eff14228af
commit 25b33b6a0e
38 changed files with 188 additions and 630 deletions

View File

@@ -1,13 +1,8 @@
use std::path::PathBuf;
/// Configuration for the local files media provider.
pub struct LocalFilesConfig {
/// Root directory containing video files. All files are served relative to this.
pub root_dir: PathBuf,
/// Public base URL of this API server, used to build stream URLs.
pub base_url: String,
/// Directory for FFmpeg HLS transcode cache. `None` disables transcoding.
pub transcode_dir: Option<PathBuf>,
/// How long (hours) to keep transcode cache entries. Passed to TranscodeManager.
pub cleanup_ttl_hours: u32,
}

View File

@@ -11,7 +11,6 @@ use domain::MediaItemId;
use crate::config::LocalFilesConfig;
use crate::scanner::{scan_dir, LocalFileItem};
/// Encode a rel-path string into a URL-safe, padding-free base64 MediaItemId.
pub fn encode_id(rel_path: &str) -> MediaItemId {
use base64::Engine as _;
MediaItemId::new(
@@ -19,7 +18,6 @@ pub fn encode_id(rel_path: &str) -> MediaItemId {
)
}
/// Decode a MediaItemId back to a relative path string.
pub fn decode_id(id: &MediaItemId) -> Option<String> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
@@ -28,11 +26,6 @@ pub fn decode_id(id: &MediaItemId) -> Option<String> {
String::from_utf8(bytes).ok()
}
/// In-memory (+ SQLite-backed) index of local video files.
///
/// On startup the index is populated from the SQLite cache so the provider can
/// serve requests immediately. A background task calls `rescan()` to pick up
/// any changes on disk and write them back to the cache.
pub struct LocalIndex {
items: Arc<RwLock<HashMap<MediaItemId, LocalFileItem>>>,
pub root_dir: PathBuf,
@@ -41,7 +34,6 @@ pub struct LocalIndex {
}
impl LocalIndex {
/// Create the index, immediately loading persisted entries from SQLite.
pub async fn new(
config: &LocalFilesConfig,
pool: sqlx::SqlitePool,
@@ -57,7 +49,6 @@ impl LocalIndex {
idx
}
/// Load previously scanned items from SQLite (instant on startup).
async fn load_from_db(&self) {
#[derive(sqlx::FromRow)]
struct Row {
@@ -107,10 +98,6 @@ impl LocalIndex {
}
}
/// Scan the filesystem for video files and rebuild the index.
///
/// Returns the number of items found. Called on startup (background task)
/// and via `POST /files/rescan`.
pub async fn rescan(&self) -> u32 {
info!(
"Local files [{}]: scanning {:?}",
@@ -119,7 +106,6 @@ impl LocalIndex {
let new_items = scan_dir(&self.root_dir).await;
let count = new_items.len() as u32;
// Swap in-memory map.
{
let mut map = self.items.write().await;
map.clear();
@@ -129,7 +115,6 @@ impl LocalIndex {
}
}
// Persist to SQLite.
if let Err(e) = self.save_to_db(&new_items).await {
error!("Failed to persist local files index: {}", e);
}
@@ -142,7 +127,6 @@ impl LocalIndex {
}
async fn save_to_db(&self, items: &[LocalFileItem]) -> Result<(), sqlx::Error> {
// Rebuild the table in one transaction, scoped to this provider.
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM local_files_index WHERE provider_id = ?")
@@ -189,7 +173,6 @@ impl LocalIndex {
.collect()
}
/// Return unique top-level directories as collection names.
pub async fn collections(&self) -> Vec<String> {
let map = self.items.read().await;
let mut seen = std::collections::HashSet::new();

View File

@@ -1,9 +1,3 @@
//! Local-files media provider adapter.
//!
//! Implements [`domain::ports::IMediaProvider`] by scanning a local filesystem
//! directory for video files. Optional FFmpeg HLS transcoding via
//! [`TranscodeManager`].
pub mod config;
pub mod index;
pub mod provider;
@@ -17,7 +11,6 @@ pub use transcoder::TranscodeManager;
use std::sync::Arc;
/// Bundle of all local-files components, constructed once at startup.
pub struct LocalFilesBundle {
pub provider: LocalFilesProvider,
pub local_index: Arc<LocalIndex>,
@@ -25,10 +18,6 @@ pub struct LocalFilesBundle {
}
impl LocalFilesBundle {
/// Build the bundle from config and a SQLite pool.
///
/// If `config.transcode_dir` is `Some`, a `TranscodeManager` is created
/// with its background cleanup task.
pub async fn build(
config: LocalFilesConfig,
pool: sqlx::SqlitePool,

View File

@@ -17,7 +17,8 @@ pub struct LocalFilesProvider {
transcode_manager: Option<Arc<TranscodeManager>>,
}
const SHORT_DURATION_SECS: u32 = 1200; // 20 minutes
const SHORT_DURATION_SECS: u32 = 1200;
const DECADE_SPAN: u16 = 9;
impl LocalFilesProvider {
pub fn new(
@@ -44,15 +45,15 @@ fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem {
item.title.clone(),
content_type,
item.duration_secs,
None, // description
vec![], // genres
None,
vec![],
item.year,
item.tags.clone(),
None, // series_name
None, // season_number
None, // episode_number
None, // thumbnail_url
None, // collection_id
None,
None,
None,
None,
None,
)
}
@@ -82,26 +83,23 @@ impl IMediaProvider for LocalFilesProvider {
let results = all
.into_iter()
.filter_map(|(id, item)| {
// content_type: derive heuristically, then filter
let content_type = if item.duration_secs < SHORT_DURATION_SECS {
ContentType::Short
} else {
ContentType::Movie
};
if let Some(ref ct) = filter.content_type {
if &content_type != ct {
return None;
}
if let Some(ref ct) = filter.content_type
&& &content_type != ct
{
return None;
}
// collections: match against top_dir
if !filter.collections.is_empty()
&& !filter.collections.contains(&item.top_dir)
{
return None;
}
// tags: OR -- item must have at least one matching tag
if !filter.tags.is_empty() {
let has = filter
.tags
@@ -112,31 +110,28 @@ impl IMediaProvider for LocalFilesProvider {
}
}
// decade: year in [decade, decade+9]
if let Some(decade) = filter.decade {
match item.year {
Some(y) if y >= decade && y <= decade + 9 => {}
Some(y) if y >= decade && y <= decade + DECADE_SPAN => {}
_ => return None,
}
}
// duration bounds
if let Some(min) = filter.min_duration_secs {
if item.duration_secs < min {
return None;
}
if let Some(min) = filter.min_duration_secs
&& item.duration_secs < min
{
return None;
}
if let Some(max) = filter.max_duration_secs {
if item.duration_secs > max {
return None;
}
if let Some(max) = filter.max_duration_secs
&& item.duration_secs > max
{
return None;
}
// search_term: case-insensitive substring in title
if let Some(ref q) = filter.search_term {
if !item.title.to_lowercase().contains(&q.to_lowercase()) {
return None;
}
if let Some(ref q) = filter.search_term
&& !item.title.to_lowercase().contains(&q.to_lowercase())
{
return None;
}
Some(to_media_item(id, &item))
@@ -194,7 +189,6 @@ impl IMediaProvider for LocalFilesProvider {
}
}
/// Decode an encoded ID from a URL path segment to its relative path string.
pub fn decode_stream_id(encoded: &str) -> Option<String> {
decode_id(&MediaItemId::new(encoded))
}

View File

@@ -2,25 +2,21 @@ 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;
/// 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();
@@ -34,33 +30,29 @@ pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
.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(),
match ext {
Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => {}
_ => 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
.filter(|_| rel.components().count() > 1)
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.unwrap_or_else(|| "__root__".to_string());
.unwrap_or_else(|| ROOT_COLLECTION_NAME.to_string());
// Title: stem with separator chars replaced by spaces.
let stem = path
.file_stem()
.and_then(|s| s.to_str())
@@ -69,7 +61,6 @@ pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
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,
@@ -79,7 +70,6 @@ pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
);
let year = extract_year(&search_str);
// Tags: ancestor directory components between root and the file.
let tags: Vec<String> = rel
.parent()
.into_iter()
@@ -103,27 +93,23 @@ pub async fn scan_dir(root: &Path) -> Vec<LocalFileItem> {
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 {
if n < YEAR_DIGITS {
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()) {
for i in 0..=(n - YEAR_DIGITS) {
if !chars[i..i + YEAR_DIGITS].iter().all(|c| c.is_ascii_digit()) {
continue;
}
// Parse and range-check.
let s4: String = chars[i..i + 4].iter().collect();
let s4: String = chars[i..i + YEAR_DIGITS].iter().collect();
let num: u16 = s4.parse().ok()?;
if !(1900..=2099).contains(&num) {
if !(MIN_YEAR..=MAX_YEAR).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 after_ok = i + YEAR_DIGITS >= n || !chars[i + YEAR_DIGITS].is_ascii_digit();
if before_ok && after_ok {
return Some(num);
}
@@ -131,7 +117,6 @@ fn extract_year(s: &str) -> Option<u16> {
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 {
@@ -169,8 +154,8 @@ mod tests {
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); // 5-digit number
assert_eq!(extract_year("2100"), None); // out of range
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));
}

View File

@@ -1,11 +1,3 @@
//! FFmpeg HLS transcoder for local video files.
//!
//! `TranscodeManager` orchestrates on-demand transcoding: the first request for
//! an item spawns an ffmpeg process and returns once the initial HLS playlist
//! appears. Concurrent requests for the same item subscribe to a watch channel
//! and wait without spawning duplicate processes. Transcoded segments are cached
//! in `transcode_dir/{item_id}/` and cleaned up by a background task.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{
@@ -19,9 +11,13 @@ use tracing::{error, info, warn};
use domain::{DomainError, DomainResult};
// ============================================================================
// Types
// ============================================================================
const SECS_PER_HOUR: u64 = 3600;
const CLEANUP_INTERVAL: Duration = Duration::from_secs(SECS_PER_HOUR);
const TRANSCODE_TIMEOUT: Duration = Duration::from_secs(60);
const TRANSCODE_POLL_INTERVAL: Duration = Duration::from_millis(100);
const FFMPEG_CRF: &str = "23";
const FFMPEG_AUDIO_BITRATE: &str = "128k";
const HLS_SEGMENT_SECS: &str = "6";
#[derive(Clone, Debug)]
pub enum TranscodeStatus {
@@ -29,10 +25,6 @@ pub enum TranscodeStatus {
Failed(String),
}
// ============================================================================
// Manager
// ============================================================================
pub struct TranscodeManager {
pub transcode_dir: PathBuf,
cleanup_ttl_hours: Arc<AtomicU32>,
@@ -46,10 +38,10 @@ impl TranscodeManager {
cleanup_ttl_hours: Arc::new(AtomicU32::new(cleanup_ttl_hours)),
active: Arc::new(Mutex::new(HashMap::new())),
});
// Background cleanup task -- uses Weak to avoid keeping manager alive.
// uses Weak to avoid keeping manager alive
let weak = Arc::downgrade(&mgr);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(3600));
let mut interval = tokio::time::interval(CLEANUP_INTERVAL);
loop {
interval.tick().await;
match weak.upgrade() {
@@ -61,7 +53,6 @@ impl TranscodeManager {
mgr
}
/// Update the cleanup TTL (also persisted to DB by the route handler).
pub fn set_cleanup_ttl(&self, hours: u32) {
self.cleanup_ttl_hours.store(hours, Ordering::Relaxed);
}
@@ -70,8 +61,6 @@ impl TranscodeManager {
self.cleanup_ttl_hours.load(Ordering::Relaxed)
}
/// Ensure `item_id` has been transcoded to HLS. Blocks until the initial
/// playlist appears or an error occurs. Concurrent callers share the result.
pub async fn ensure_transcoded(&self, item_id: &str, src_path: &Path) -> DomainResult<()> {
let out_dir = self.transcode_dir.join(item_id);
let playlist = out_dir.join("playlist.m3u8");
@@ -111,7 +100,6 @@ impl TranscodeManager {
}
};
// Wait for Ready or Failed.
loop {
rx.changed().await.map_err(|_| {
DomainError::InfrastructureError(
@@ -129,7 +117,6 @@ impl TranscodeManager {
}
}
/// Remove all cached transcode directories.
pub async fn clear_cache(&self) -> std::io::Result<()> {
if self.transcode_dir.exists() {
tokio::fs::remove_dir_all(&self.transcode_dir).await?;
@@ -137,7 +124,6 @@ impl TranscodeManager {
tokio::fs::create_dir_all(&self.transcode_dir).await
}
/// Return `(total_bytes, item_count)` for the cache directory.
pub async fn cache_stats(&self) -> (u64, usize) {
let mut total_bytes = 0u64;
let mut item_count = 0usize;
@@ -162,7 +148,7 @@ impl TranscodeManager {
async fn run_cleanup(&self) {
let ttl_hours = self.cleanup_ttl_hours.load(Ordering::Relaxed) as u64;
let ttl = Duration::from_secs(ttl_hours * 3600);
let ttl = Duration::from_secs(ttl_hours * SECS_PER_HOUR);
let now = std::time::SystemTime::now();
let Ok(mut entries) = tokio::fs::read_dir(&self.transcode_dir).await else {
@@ -174,24 +160,18 @@ impl TranscodeManager {
continue;
}
let playlist = path.join("playlist.m3u8");
if let Ok(meta) = tokio::fs::metadata(&playlist).await {
if let Ok(modified) = meta.modified() {
if let Ok(age) = now.duration_since(modified) {
if age > ttl {
warn!("cleanup: removing stale transcode {:?}", path);
let _ = tokio::fs::remove_dir_all(&path).await;
}
}
}
if let Ok(meta) = tokio::fs::metadata(&playlist).await
&& let Ok(modified) = meta.modified()
&& let Ok(age) = now.duration_since(modified)
&& age > ttl
{
warn!("cleanup: removing stale transcode {:?}", path);
let _ = tokio::fs::remove_dir_all(&path).await;
}
}
}
}
// ============================================================================
// FFmpeg helper
// ============================================================================
async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeStatus {
let segment_pattern = out_dir.join("seg%05d.ts");
@@ -204,13 +184,13 @@ async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeS
"-preset",
"fast",
"-crf",
"23",
FFMPEG_CRF,
"-c:a",
"aac",
"-b:a",
"128k",
FFMPEG_AUDIO_BITRATE,
"-hls_time",
"6",
HLS_SEGMENT_SECS,
"-hls_list_size",
"0",
"-hls_flags",
@@ -227,10 +207,8 @@ async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeS
Err(e) => return TranscodeStatus::Failed(format!("ffmpeg spawn error: {}", e)),
};
// Poll for playlist.m3u8 -- it appears after the first segment is written,
// allowing the client to start playback before transcoding is complete.
let start = Instant::now();
let timeout = Duration::from_secs(60);
let timeout = TRANSCODE_TIMEOUT;
loop {
if playlist.exists() {
return TranscodeStatus::Ready;
@@ -258,6 +236,6 @@ async fn do_transcode(src: &Path, out_dir: &Path, playlist: &Path) -> TranscodeS
Err(e) => return TranscodeStatus::Failed(e.to_string()),
Ok(None) => {}
}
tokio::time::sleep(Duration::from_millis(100)).await;
tokio::time::sleep(TRANSCODE_POLL_INTERVAL).await;
}
}