Merge pull request 'feature/library-patch-endpoint' (#94) from feature/library-patch-endpoint into master

Reviewed-on: #94
This commit was merged in pull request #94.
This commit is contained in:
2026-05-13 13:44:36 +00:00
4 changed files with 532 additions and 37 deletions

View File

@@ -1,8 +1,9 @@
use actix_web::{HttpResponse, Responder, get, web::Data}; use actix_web::{HttpResponse, Responder, get, patch, web, web::Data};
use chrono::Utc; use chrono::Utc;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sqlite::SqliteConnection; use diesel::sqlite::SqliteConnection;
use log::{info, warn}; use log::{info, warn};
use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
@@ -79,14 +80,21 @@ impl Library {
} }
} }
/// Parse a comma-separated excluded_dirs column into a Vec, dropping /// Parse an excluded_dirs string into a Vec, dropping empty entries.
/// empty entries (mirrors `AppState::parse_excluded_dirs` for the env /// NULL → empty Vec. Duplicates are preserved — `PathExcluder` accepts
/// var). NULL → empty Vec. /// repeats, and the storage-side normaliser is where dedup happens.
///
/// Accepts both `,` and newline (`\n` / `\r\n`) as separators so the
/// UI's textarea can submit one-entry-per-line input without forcing
/// the operator to remember commas. The DB stores the canonical
/// comma-joined form (see `normalize_excluded_dirs_input`); the
/// newline path matters mostly for the frontend submit, but mirroring
/// it here keeps the parse direction round-trip safe.
pub fn parse_excluded_dirs_column(raw: Option<&str>) -> Vec<String> { pub fn parse_excluded_dirs_column(raw: Option<&str>) -> Vec<String> {
match raw { match raw {
None => Vec::new(), None => Vec::new(),
Some(s) => s Some(s) => s
.split(',') .split(|c: char| matches!(c, ',' | '\n' | '\r'))
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(String::from) .map(String::from)
@@ -94,6 +102,121 @@ pub fn parse_excluded_dirs_column(raw: Option<&str>) -> Vec<String> {
} }
} }
/// Validate a single excluded_dirs entry, normalising trivial cosmetic
/// differences and rejecting forms that `PathExcluder` would silently
/// drop. Returns the entry to store, or an error message describing
/// what's wrong with it.
///
/// Rules:
/// - Backslashes are rejected — PathExcluder strips only a leading `/`;
/// a Windows-typed `\photos` or `photos\2024` lands in the
/// component-pattern bucket and never matches anything. Suggest the
/// forward-slash form.
/// - A Windows drive letter prefix (`Z:` etc.) is rejected — excluded
/// entries are *relative to the library root*, not absolute system
/// paths.
/// - A no-leading-slash entry containing `/` is rejected — the
/// component-pattern path matches a single segment only; the user
/// almost certainly meant the leading-slash form.
/// - A `..` segment in a path entry is rejected — `base.join("../x")`
/// doesn't canonicalise, so the resulting prefix never matches and
/// the exclude silently fails.
/// - Trailing slashes on path entries are stripped silently
/// (`/photos/` → `/photos`) — purely cosmetic.
pub fn validate_excluded_dirs_entry(entry: &str) -> Result<String, String> {
let trimmed = entry.trim();
if trimmed.is_empty() {
return Err("empty entry".to_string());
}
if trimmed.contains('\\') {
return Err(format!(
"'{}': use forward slashes — backslash paths never match on the watcher's component-by-component compare",
trimmed
));
}
// Windows drive letter prefix like `Z:` or `Z:/something`. A
// length-2 ASCII-alpha + colon is the canonical form; we don't
// bother with longer multi-letter Windows drive-equivalents
// (`\\?\Volume{…}`) since the backslash check already catches them.
let bytes = trimmed.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return Err(format!(
"'{}': excluded entries are relative to the library root, not absolute system paths — drop the drive letter",
trimmed
));
}
if let Some(rel) = trimmed.strip_prefix('/') {
// Path form. Reject `..` traversal — `base.join(\"../x\")` doesn't
// canonicalise, so `path.starts_with(...)` never matches.
if rel
.split('/')
.any(|seg| seg == "..")
{
return Err(format!(
"'{}': '..' segments don't normalise — the prefix-match never fires",
trimmed
));
}
// Strip a trailing slash if any (`/photos/` → `/photos`). Purely
// cosmetic; PathBuf::starts_with treats both forms identically.
let stripped = if rel.ends_with('/') {
format!("/{}", rel.trim_end_matches('/'))
} else {
trimmed.to_string()
};
// After stripping, an empty rel ("/" alone) excludes the root —
// certainly a typo.
if stripped == "/" {
return Err("'/': excluding the library root is almost certainly a typo".to_string());
}
Ok(stripped)
} else {
// Component-pattern form: must be a single segment. A `/`
// anywhere here is the common "I forgot the leading slash" typo
// — reject so the user fixes it instead of staring at an
// exclude that does nothing.
if trimmed.contains('/') {
return Err(format!(
"'{}': multi-segment names only match with a leading slash — try '/{}'",
trimmed, trimmed
));
}
Ok(trimmed.to_string())
}
}
/// Canonicalise an excluded_dirs string for storage: validate each
/// entry, then parse → trim → dedupe (preserving insertion order) →
/// comma-join with no inner whitespace. Empty / whitespace-only input
/// → `Ok(None)` (writes NULL). Any entry that fails validation aborts
/// the whole patch with a descriptive error so the operator can fix
/// the typo before retrying.
///
/// Used by `PATCH /libraries/{id}` so two users typing the same entries
/// in different orders / casings / whitespace land on the same stored
/// form, and a typo'd duplicate (`@eaDir, @eaDir`) collapses on save.
/// Round-trip stable: writing the output back through this function
/// yields the same string.
pub fn normalize_excluded_dirs_input(raw: &str) -> Result<Option<String>, String> {
let parsed = parse_excluded_dirs_column(Some(raw));
if parsed.is_empty() {
return Ok(None);
}
let mut seen = std::collections::HashSet::new();
let mut deduped: Vec<String> = Vec::with_capacity(parsed.len());
for entry in parsed {
let validated = validate_excluded_dirs_entry(&entry)?;
if seen.insert(validated.clone()) {
deduped.push(validated);
}
}
if deduped.is_empty() {
Ok(None)
} else {
Ok(Some(deduped.join(",")))
}
}
impl From<LibraryRow> for Library { impl From<LibraryRow> for Library {
fn from(row: LibraryRow) -> Self { fn from(row: LibraryRow) -> Self {
Library { Library {
@@ -334,16 +457,30 @@ pub struct LibraryStatus {
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct LibrariesResponse { pub struct LibrariesResponse {
pub libraries: Vec<LibraryStatus>, pub libraries: Vec<LibraryStatus>,
/// Globally-excluded paths/patterns from the `EXCLUDED_DIRS` env var.
/// Applied **in union** with each library's own `excluded_dirs`. Surfaced
/// here so an admin UI can show the operator "you already skip these
/// everywhere" before they add per-library entries that would duplicate
/// the global list. Read-only — globals live in `.env` and aren't
/// mutable via the API today.
pub global_excluded_dirs: Vec<String>,
} }
#[get("/libraries")] #[get("/libraries")]
pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> impl Responder { pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> impl Responder {
// Read from the live view so a recent PATCH /libraries/{id} that
// flipped `enabled` or rewrote `excluded_dirs` surfaces immediately
// — the immutable `app_state.libraries` snapshot is stale once the
// first mutation lands.
let live_guard = app_state
.live_libraries
.read()
.unwrap_or_else(|e| e.into_inner());
let health_guard = app_state let health_guard = app_state
.library_health .library_health
.read() .read()
.unwrap_or_else(|e| e.into_inner()); .unwrap_or_else(|e| e.into_inner());
let libraries = app_state let libraries = live_guard
.libraries
.iter() .iter()
.map(|lib| LibraryStatus { .map(|lib| LibraryStatus {
library: lib.clone(), library: lib.clone(),
@@ -353,7 +490,118 @@ pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> impl
.unwrap_or(LibraryHealth::Online), .unwrap_or(LibraryHealth::Online),
}) })
.collect(); .collect();
HttpResponse::Ok().json(LibrariesResponse { libraries }) HttpResponse::Ok().json(LibrariesResponse {
libraries,
global_excluded_dirs: app_state.excluded_dirs.clone(),
})
}
/// Body for PATCH /libraries/{id}. Both fields are optional — omitting
/// one leaves it untouched. `excluded_dirs` is the same comma-separated
/// shape as the DB column; an empty string clears (writes NULL).
#[derive(Deserialize, Debug)]
pub struct PatchLibraryBody {
pub enabled: Option<bool>,
pub excluded_dirs: Option<String>,
}
/// Mutate one library row. The watcher reads `app_state.live_libraries`
/// at the top of each tick, so a successful PATCH is picked up within
/// one WATCH_QUICK_INTERVAL_SECONDS without restart — no separate
/// `apply_now` signal. Returns the updated `Library` so the caller can
/// render the new state without a follow-up GET.
///
/// Despite CLAUDE.md noting "Toggle via SQL; there is intentionally no
/// HTTP endpoint for library mutation", we now expose this for Apollo's
/// Settings panel. The single-user trust model hasn't changed; the
/// endpoint just removes the SSH-and-sqlite3 step.
#[patch("/libraries/{id}")]
pub async fn patch_library(
_claims: Claims,
path: web::Path<i32>,
body: web::Json<PatchLibraryBody>,
app_state: Data<AppState>,
) -> impl Responder {
let lib_id = path.into_inner();
let body = body.into_inner();
if body.enabled.is_none() && body.excluded_dirs.is_none() {
return HttpResponse::UnprocessableEntity().body("empty patch body");
}
let mut conn = crate::database::connect();
// Build the SET clause. Diesel's set() takes a tuple of assignments;
// we apply each field independently so an absent field doesn't get
// forced to NULL / its default.
let mut affected = 0usize;
if let Some(enabled) = body.enabled {
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
.set(libraries::enabled.eq(enabled))
.execute(&mut conn)
{
Ok(n) => affected = affected.max(n),
Err(e) => {
warn!("PATCH /libraries/{}: enabled update failed: {:?}", lib_id, e);
return HttpResponse::InternalServerError().body(format!("{}", e));
}
}
}
if let Some(raw) = body.excluded_dirs.as_deref() {
// Canonicalise on write — trim, dedupe, validate, drop empties —
// so the DB stores a round-trip-stable form regardless of how
// messy the user typed it. Empty / whitespace-only → NULL
// (matches a never-set library). Validation failures (Windows
// backslash paths, drive letters, `..` traversal, etc.) bounce
// back as 422 so the operator can fix the typo.
let normalised = match normalize_excluded_dirs_input(raw) {
Ok(v) => v,
Err(msg) => return HttpResponse::UnprocessableEntity().body(msg),
};
let stored: Option<&str> = normalised.as_deref();
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
.set(libraries::excluded_dirs.eq(stored))
.execute(&mut conn)
{
Ok(n) => affected = affected.max(n),
Err(e) => {
warn!(
"PATCH /libraries/{}: excluded_dirs update failed: {:?}",
lib_id, e
);
return HttpResponse::InternalServerError().body(format!("{}", e));
}
}
}
if affected == 0 {
return HttpResponse::NotFound().body(format!("library id {} not found", lib_id));
}
// Refresh the live view from the canonical DB state. Reloading the
// whole table (rather than mutating one entry in place) is cheap
// (handful of rows) and keeps the in-memory and DB views trivially
// consistent.
let fresh = load_all(&mut conn);
let updated = fresh.iter().find(|l| l.id == lib_id).cloned();
{
let mut live = app_state
.live_libraries
.write()
.unwrap_or_else(|e| e.into_inner());
*live = fresh;
}
match updated {
Some(lib) => {
info!(
"PATCH /libraries/{}: enabled={:?} excluded_dirs={:?} → applied",
lib_id, body.enabled, body.excluded_dirs
);
HttpResponse::Ok().json(lib)
}
None => HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id)),
}
} }
#[cfg(test)] #[cfg(test)]
@@ -496,6 +744,40 @@ mod tests {
); );
} }
#[test]
fn parse_excluded_dirs_column_splits_on_newlines_too() {
// Newline-separated input from a textarea submit. One-per-line
// is the recommended UX because "I forgot the comma" was a
// recurring footgun (.thumbnails .thumbnails2 silently
// becomes a single never-matching pattern).
assert_eq!(
parse_excluded_dirs_column(Some("@eaDir\n.thumbnails\n/private")),
vec![
"@eaDir".to_string(),
".thumbnails".to_string(),
"/private".to_string()
]
);
// Windows line endings (CRLF) — the carriage return is its own
// separator so the trailing empty token between \r and \n gets
// trimmed + dropped.
assert_eq!(
parse_excluded_dirs_column(Some("a\r\nb\r\nc")),
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
// Mixed comma + newline — the user pastes from one source,
// adds a few entries inline. Both work, in any combination.
assert_eq!(
parse_excluded_dirs_column(Some("a, b\nc,d")),
vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string()
]
);
}
#[test] #[test]
fn effective_excluded_dirs_unions_global_and_per_library() { fn effective_excluded_dirs_unions_global_and_per_library() {
let lib_no_extras = Library { let lib_no_extras = Library {
@@ -523,6 +805,178 @@ mod tests {
assert_eq!(combined.len(), 3); assert_eq!(combined.len(), 3);
} }
#[test]
fn effective_excluded_dirs_keeps_overlap_between_global_and_per_library() {
// Two sources both excluding `@eaDir` is legal — `PathExcluder`
// accepts repeats, and there's no behavioral reason to dedupe
// here. Documents the design choice so a future refactor that
// tightens this is forced to update both code and tests.
let globals = vec!["@eaDir".to_string()];
let lib = Library {
id: 1,
name: "main".into(),
root_path: "/x".into(),
enabled: true,
excluded_dirs: vec!["@eaDir".to_string(), "/private".to_string()],
};
let combined = lib.effective_excluded_dirs(&globals);
// 2 occurrences of @eaDir + /private = 3 entries total.
assert_eq!(combined, vec!["@eaDir", "@eaDir", "/private"]);
}
#[test]
fn normalize_excluded_dirs_input_handles_empty_and_whitespace() {
assert_eq!(normalize_excluded_dirs_input(""), Ok(None));
assert_eq!(normalize_excluded_dirs_input(" "), Ok(None));
assert_eq!(normalize_excluded_dirs_input(",,,"), Ok(None));
assert_eq!(normalize_excluded_dirs_input(" , , "), Ok(None));
}
#[test]
fn normalize_excluded_dirs_input_trims_per_entry() {
// Inner whitespace stripped on each item, comma-joined without
// spaces. Mirrors how parse_excluded_dirs_column reads it back.
assert_eq!(
normalize_excluded_dirs_input(" @eaDir , /private , .thumbnails "),
Ok(Some("@eaDir,/private,.thumbnails".to_string()))
);
}
#[test]
fn normalize_excluded_dirs_input_dedupes_preserving_first_occurrence() {
// Exact-string duplicates collapse; the first occurrence wins
// (preserves the operator's typed order so they recognise their
// intent on round-trip).
assert_eq!(
normalize_excluded_dirs_input("@eaDir, /private, @eaDir, /private"),
Ok(Some("@eaDir,/private".to_string()))
);
// Whitespace-distinct entries collapse to the same canonical
// form. Case is preserved — `Foo` and `foo` are different keys
// (filesystem case-sensitivity is platform-dependent; we don't
// make that call here).
assert_eq!(
normalize_excluded_dirs_input(" Foo,foo, Foo "),
Ok(Some("Foo,foo".to_string()))
);
}
#[test]
fn normalize_excluded_dirs_input_is_round_trip_stable() {
// Writing the normaliser's output back through it yields the
// same string. PATCH-clearing edits round-trip cleanly through
// parse_excluded_dirs_column too.
let raw = " /a/b ,, /a/b , c ";
let once = normalize_excluded_dirs_input(raw)
.expect("validation passes")
.expect("not empty");
let twice = normalize_excluded_dirs_input(&once)
.expect("validation passes")
.expect("not empty");
assert_eq!(once, twice);
// Parsing the stored form back gives the deduped Vec.
assert_eq!(
parse_excluded_dirs_column(Some(&once)),
vec!["/a/b".to_string(), "c".to_string()]
);
}
#[test]
fn validate_rejects_backslash_paths() {
// Windows-typed entries land in the component-pattern bucket
// and never match — reject so the user gets feedback instead
// of a silent no-op.
assert!(validate_excluded_dirs_entry(r"\photos").is_err());
assert!(validate_excluded_dirs_entry(r"photos\2024").is_err());
assert!(validate_excluded_dirs_entry(r"\\server\share").is_err());
// The error message names the entry and points at the fix.
let err = validate_excluded_dirs_entry(r"\photos").unwrap_err();
assert!(err.contains("forward slashes"), "{}", err);
}
#[test]
fn validate_rejects_windows_drive_letters() {
assert!(validate_excluded_dirs_entry("Z:/photos").is_err());
assert!(validate_excluded_dirs_entry("z:photos").is_err());
// Single-letter alpha + colon is the canonical drive prefix;
// the message should steer toward the relative form.
let err = validate_excluded_dirs_entry("Z:/foo").unwrap_err();
assert!(err.contains("relative to the library root"), "{}", err);
}
#[test]
fn validate_rejects_multi_segment_name_without_leading_slash() {
// The common "I forgot the slash" typo. Today this would store
// a never-matching component pattern; we catch it.
let err = validate_excluded_dirs_entry("photos/2024").unwrap_err();
assert!(err.contains("multi-segment"), "{}", err);
// And the suggestion shows the corrected form.
assert!(err.contains("/photos/2024"), "{}", err);
}
#[test]
fn validate_rejects_parent_dir_traversal_in_path_entries() {
// base.join("../sensitive") doesn't canonicalise, so the
// resulting prefix never starts_with anything the walker sees.
assert!(validate_excluded_dirs_entry("/../secret").is_err());
assert!(validate_excluded_dirs_entry("/photos/../keys").is_err());
// Same string as a non-leading-slash component is fine — it
// just never matches (you'd literally need a directory named
// `..` which is impossible on every filesystem we care about),
// but the validator accepts it because the failure mode isn't
// a silent footgun in that direction.
assert!(validate_excluded_dirs_entry("..").is_ok());
}
#[test]
fn validate_strips_trailing_slash_on_path_entries() {
assert_eq!(
validate_excluded_dirs_entry("/photos/").unwrap(),
"/photos"
);
assert_eq!(
validate_excluded_dirs_entry("/photos//").unwrap(),
"/photos"
);
// Bare "/" is rejected — almost certainly a typo for the
// library root.
assert!(validate_excluded_dirs_entry("/").is_err());
assert!(validate_excluded_dirs_entry("///").is_err());
}
#[test]
fn validate_passes_valid_entries() {
for entry in &[
"/photos",
"/photos/2024",
"/media/raw/private",
"@eaDir",
".thumbnails",
".DS_Store",
"node_modules",
] {
assert!(
validate_excluded_dirs_entry(entry).is_ok(),
"expected {} to pass",
entry
);
}
}
#[test]
fn normalize_aborts_on_invalid_entry() {
// One bad entry kills the whole patch — better to surface the
// problem than to silently apply N-1 of N changes.
let err = normalize_excluded_dirs_input("/photos, photos/2024").unwrap_err();
assert!(err.contains("photos/2024"), "{}", err);
// A valid mix succeeds — the bad-entry test isn't accidentally
// matching the good prefix.
assert_eq!(
normalize_excluded_dirs_input("/photos, @eaDir, /private/"),
Ok(Some("/photos,@eaDir,/private".to_string()))
);
}
fn probe_lib(id: i32, root: String) -> Library { fn probe_lib(id: i32, root: String) -> Library {
Library { Library {
id, id,

View File

@@ -128,8 +128,12 @@ fn main() -> std::io::Result<()> {
// Start file watcher with playlist manager and preview generator // Start file watcher with playlist manager and preview generator
let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone(); let playlist_mgr_for_watcher = app_state.playlist_manager.as_ref().clone();
let preview_gen_for_watcher = app_state.preview_clip_generator.as_ref().clone(); let preview_gen_for_watcher = app_state.preview_clip_generator.as_ref().clone();
// Both background jobs read from the shared `live_libraries` lock
// so a PATCH /libraries/{id} that flips `enabled` or edits
// `excluded_dirs` takes effect on the next watcher tick / cleanup
// tick without an ImageApi restart.
watcher::watch_files( watcher::watch_files(
app_state.libraries.clone(), app_state.live_libraries.clone(),
playlist_mgr_for_watcher, playlist_mgr_for_watcher,
preview_gen_for_watcher, preview_gen_for_watcher,
app_state.face_client.clone(), app_state.face_client.clone(),
@@ -142,7 +146,7 @@ fn main() -> std::io::Result<()> {
// skips the whole cycle while any library is stale (a missing // skips the whole cycle while any library is stale (a missing
// source is indistinguishable from a transiently-unmounted share). // source is indistinguishable from a transiently-unmounted share).
watcher::cleanup_orphaned_playlists( watcher::cleanup_orphaned_playlists(
app_state.libraries.clone(), app_state.live_libraries.clone(),
app_state.excluded_dirs.clone(), app_state.excluded_dirs.clone(),
app_state.library_health.clone(), app_state.library_health.clone(),
); );
@@ -280,6 +284,7 @@ fn main() -> std::io::Result<()> {
.service(ai::rate_insight_handler) .service(ai::rate_insight_handler)
.service(ai::export_training_data_handler) .service(ai::export_training_data_handler)
.service(libraries::list_libraries) .service(libraries::list_libraries)
.service(libraries::patch_library)
.add_feature(add_tag_services::<_, SqliteTagDao>) .add_feature(add_tag_services::<_, SqliteTagDao>)
.add_feature(knowledge::add_knowledge_services::<_, SqliteKnowledgeDao>) .add_feature(knowledge::add_knowledge_services::<_, SqliteKnowledgeDao>)
.add_feature(personas::add_persona_services) .add_feature(personas::add_persona_services)

View File

@@ -18,15 +18,25 @@ use crate::video::actors::{
}; };
use actix::{Actor, Addr}; use actix::{Actor, Addr};
use std::env; use std::env;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, RwLock};
pub struct AppState { pub struct AppState {
pub stream_manager: Arc<Addr<StreamActor>>, pub stream_manager: Arc<Addr<StreamActor>>,
pub playlist_manager: Arc<Addr<VideoPlaylistManager>>, pub playlist_manager: Arc<Addr<VideoPlaylistManager>>,
pub preview_clip_generator: Arc<Addr<PreviewClipGenerator>>, pub preview_clip_generator: Arc<Addr<PreviewClipGenerator>>,
/// All configured media libraries. Ordered by `id` ascending; the first /// All configured media libraries. Ordered by `id` ascending; the first
/// entry is the primary library. /// entry is the primary library. Frozen at startup — handlers that
/// only need stable lookup (id → name / root_path) read this. Mutable
/// flags (`enabled`, `excluded_dirs`) reflect their startup values;
/// for live state see [`AppState::live_libraries`].
pub libraries: Vec<Library>, pub libraries: Vec<Library>,
/// Live view of the libraries table, shared mutably between the
/// watcher (which reads it at the top of each tick to honour the
/// latest `enabled` / `excluded_dirs`) and the PATCH /libraries/{id}
/// handler (which writes it on a successful mutation). The split
/// from [`AppState::libraries`] is deliberate: handlers that only
/// look up by id don't need to take a lock per request.
pub live_libraries: Arc<RwLock<Vec<Library>>>,
/// Per-library availability snapshot. Updated by the file watcher at /// Per-library availability snapshot. Updated by the file watcher at
/// the top of each tick via `libraries::refresh_health`. HTTP handlers /// the top of each tick via `libraries::refresh_health`. HTTP handlers
/// read it (e.g. `/libraries` surfacing). See "Library availability /// read it (e.g. `/libraries` surfacing). See "Library availability
@@ -112,11 +122,13 @@ impl AppState {
); );
let library_health = libraries::new_health_map(&libraries_vec); let library_health = libraries::new_health_map(&libraries_vec);
let live_libraries = Arc::new(RwLock::new(libraries_vec.clone()));
Self { Self {
stream_manager, stream_manager,
playlist_manager: Arc::new(video_playlist_manager.start()), playlist_manager: Arc::new(video_playlist_manager.start()),
preview_clip_generator: Arc::new(preview_clip_generator.start()), preview_clip_generator: Arc::new(preview_clip_generator.start()),
libraries: libraries_vec, libraries: libraries_vec,
live_libraries,
library_health, library_health,
base_path, base_path,
thumbnail_path, thumbnail_path,

View File

@@ -16,7 +16,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use actix::Addr; use actix::Addr;
@@ -42,9 +42,13 @@ use crate::thumbnails;
use crate::video; use crate::video;
use crate::video::actors::{GeneratePreviewClipMessage, QueueVideosMessage, VideoPlaylistManager}; use crate::video::actors::{GeneratePreviewClipMessage, QueueVideosMessage, VideoPlaylistManager};
/// Clean up orphaned HLS playlists and segments whose source videos no longer exist /// Clean up orphaned HLS playlists and segments whose source videos no longer exist.
///
/// `libs_lock` is the shared live view of the libraries table — read at the
/// top of each cleanup pass so a PATCH /libraries/{id} that disables or
/// re-mounts a library is picked up without a restart.
pub fn cleanup_orphaned_playlists( pub fn cleanup_orphaned_playlists(
libs: Vec<libraries::Library>, libs_lock: Arc<RwLock<Vec<libraries::Library>>>,
excluded_dirs: Vec<String>, excluded_dirs: Vec<String>,
library_health: libraries::LibraryHealthMap, library_health: libraries::LibraryHealthMap,
) { ) {
@@ -60,16 +64,25 @@ pub fn cleanup_orphaned_playlists(
info!("Starting orphaned playlist cleanup job"); info!("Starting orphaned playlist cleanup job");
info!(" Cleanup interval: {} seconds", cleanup_interval_secs); info!(" Cleanup interval: {} seconds", cleanup_interval_secs);
info!(" Playlist directory: {}", video_path); info!(" Playlist directory: {}", video_path);
for lib in &libs { {
let libs = libs_lock.read().unwrap_or_else(|e| e.into_inner());
for lib in libs.iter() {
info!( info!(
" Checking sources under '{}' at {}", " Checking sources under '{}' at {}",
lib.name, lib.root_path lib.name, lib.root_path
); );
} }
}
loop { loop {
std::thread::sleep(Duration::from_secs(cleanup_interval_secs)); std::thread::sleep(Duration::from_secs(cleanup_interval_secs));
// Fresh snapshot per tick so a PATCH /libraries/{id} that
// disabled a library (or rewrote its excluded_dirs) is
// honoured immediately.
let libs: Vec<libraries::Library> =
libs_lock.read().unwrap_or_else(|e| e.into_inner()).clone();
// Safety gate: skip the cleanup cycle if any library is // Safety gate: skip the cleanup cycle if any library is
// stale. A missing source video on a stale library is // stale. A missing source video on a stale library is
// indistinguishable from a transient unmount, and the // indistinguishable from a transient unmount, and the
@@ -211,7 +224,7 @@ pub fn cleanup_orphaned_playlists(
} }
pub fn watch_files( pub fn watch_files(
libs: Vec<libraries::Library>, libs_lock: Arc<RwLock<Vec<libraries::Library>>>,
playlist_manager: Addr<VideoPlaylistManager>, playlist_manager: Addr<VideoPlaylistManager>,
preview_generator: Addr<video::actors::PreviewClipGenerator>, preview_generator: Addr<video::actors::PreviewClipGenerator>,
face_client: crate::ai::face_client::FaceClient, face_client: crate::ai::face_client::FaceClient,
@@ -247,12 +260,15 @@ pub fn watch_files(
or APOLLO_API_BASE_URL to enable)" or APOLLO_API_BASE_URL to enable)"
); );
} }
for lib in &libs { {
let libs = libs_lock.read().unwrap_or_else(|e| e.into_inner());
for lib in libs.iter() {
info!( info!(
" Watching library '{}' (id={}) at {}", " Watching library '{}' (id={}) at {}",
lib.name, lib.id, lib.root_path lib.name, lib.id, lib.root_path
); );
} }
}
// Create DAOs for tracking processed files // Create DAOs for tracking processed files
let exif_dao = Arc::new(Mutex::new( let exif_dao = Arc::new(Mutex::new(
@@ -306,7 +322,9 @@ pub fn watch_files(
// below; no ingest runs here, just the health update + log. // below; no ingest runs here, just the health update + log.
// Disabled libraries skip the probe entirely — they should // Disabled libraries skip the probe entirely — they should
// never enter the health map (treated as out-of-scope). // never enter the health map (treated as out-of-scope).
for lib in &libs { {
let libs = libs_lock.read().unwrap_or_else(|e| e.into_inner());
for lib in libs.iter() {
if !lib.enabled { if !lib.enabled {
continue; continue;
} }
@@ -319,6 +337,7 @@ pub fn watch_files(
.unwrap_or(false); .unwrap_or(false);
libraries::refresh_health(&library_health, lib, had_data); libraries::refresh_health(&library_health, lib, had_data);
} }
}
loop { loop {
std::thread::sleep(Duration::from_secs(quick_interval_secs)); std::thread::sleep(Duration::from_secs(quick_interval_secs));
@@ -330,6 +349,11 @@ pub fn watch_files(
let is_full_scan = since_last_full.as_secs() >= full_interval_secs; let is_full_scan = since_last_full.as_secs() >= full_interval_secs;
// Fresh snapshot per tick — picks up PATCH /libraries/{id}
// mutations to `enabled` / `excluded_dirs` without restart.
let libs: Vec<libraries::Library> =
libs_lock.read().unwrap_or_else(|e| e.into_inner()).clone();
for lib in &libs { for lib in &libs {
// Operator kill switch: a disabled library is invisible // Operator kill switch: a disabled library is invisible
// to the watcher entirely. No probe, no ingest, no // to the watcher entirely. No probe, no ingest, no