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:
Cameron Cordes
2026-05-13 08:47:35 -04:00
parent 74bf693878
commit b3124437ec
4 changed files with 187 additions and 32 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 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::*;