libraries: PATCH /libraries/{id} with live-apply
Adds an HTTP mutation surface for `libraries.enabled` and `libraries.excluded_dirs`, replacing the SQL-only workflow noted in CLAUDE.md. Apollo's Settings panel calls this from the LIBRARIES section so the operator no longer has to ssh + sqlite3 to flip a library off or edit its excludes. Live-apply (no restart) via a new `live_libraries: Arc<RwLock<Vec< Library>>>` field on AppState. The existing immutable `libraries` Vec stays for hot-path handlers that only need stable id → root_path lookups, avoiding a 19-call-site refactor. The watcher and cleanup_orphaned_playlists now take the lock instead of a Vec snapshot and re-read at the top of each tick, so `enabled` / `excluded_dirs` changes are picked up within one WATCH_QUICK_INTERVAL_SECONDS. The GET /libraries handler also reads through the live view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
120
src/libraries.rs
120
src/libraries.rs
@@ -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 diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use log::{info, warn};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
@@ -338,12 +339,19 @@ pub struct LibrariesResponse {
|
||||
|
||||
#[get("/libraries")]
|
||||
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
|
||||
.library_health
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let libraries = app_state
|
||||
.libraries
|
||||
let libraries = live_guard
|
||||
.iter()
|
||||
.map(|lib| LibraryStatus {
|
||||
library: lib.clone(),
|
||||
@@ -356,6 +364,112 @@ pub async fn list_libraries(_claims: Claims, app_state: Data<AppState>) -> impl
|
||||
HttpResponse::Ok().json(LibrariesResponse { libraries })
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let trimmed = raw.trim();
|
||||
// Empty / whitespace-only → NULL so the column reads back the
|
||||
// same way a never-set library does (parse_excluded_dirs_column
|
||||
// returns Vec::new() for NULL).
|
||||
let stored: Option<&str> = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
};
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user