feature/hls-content-hash #95

Merged
cameron merged 10 commits from feature/hls-content-hash into master 2026-05-15 20:09:49 +00:00
8 changed files with 67 additions and 62 deletions
Showing only changes of commit 8503ef7884 - Show all commits
+4 -1
View File
@@ -4548,7 +4548,10 @@ mod tests {
#[test] #[test]
fn strip_mark_tags_handles_common_patterns() { fn strip_mark_tags_handles_common_patterns() {
assert_eq!(InsightGenerator::strip_mark_tags("plain text"), "plain text"); assert_eq!(
InsightGenerator::strip_mark_tags("plain text"),
"plain text"
);
assert_eq!( assert_eq!(
InsightGenerator::strip_mark_tags("…the <mark>lake</mark>…"), InsightGenerator::strip_mark_tags("…the <mark>lake</mark>…"),
"…the lake…" "…the lake…"
+10 -4
View File
@@ -235,6 +235,7 @@ pub trait KnowledgeDao: Sync + Send {
/// - entity_type: optional, restricts nodes to one type /// - entity_type: optional, restricts nodes to one type
/// - node_limit: caps the number of nodes; lower-fact-count /// - node_limit: caps the number of nodes; lower-fact-count
/// entities drop first /// entities drop first
///
/// Edges between dropped entities are pruned. Persona scoping /// Edges between dropped entities are pruned. Persona scoping
/// affects fact_count + edge inclusion (rejected / superseded /// affects fact_count + edge inclusion (rejected / superseded
/// excluded; All vs Single mirrors the existing pattern). /// excluded; All vs Single mirrors the existing pattern).
@@ -937,7 +938,10 @@ impl KnowledgeDao for SqliteKnowledgeDao {
let mut conn = self.connection.lock().expect("KnowledgeDao lock"); let mut conn = self.connection.lock().expect("KnowledgeDao lock");
let mut q = sql_query(sql).into_boxed(); let mut q = sql_query(sql).into_boxed();
match persona { match persona {
PersonaFilter::Single { user_id, persona_id } => { PersonaFilter::Single {
user_id,
persona_id,
} => {
q = q q = q
.bind::<Integer, _>(*user_id) .bind::<Integer, _>(*user_id)
.bind::<Text, _>(persona_id.clone()); .bind::<Text, _>(persona_id.clone());
@@ -977,7 +981,10 @@ impl KnowledgeDao for SqliteKnowledgeDao {
// rows flip — REVIEWED survives so the curator can preserve // rows flip — REVIEWED survives so the curator can preserve
// a hand-approved exception under the same predicate. // a hand-approved exception under the same predicate.
let touched = match persona { let touched = match persona {
PersonaFilter::Single { user_id: uid, persona_id: pid } => diesel::update( PersonaFilter::Single {
user_id: uid,
persona_id: pid,
} => diesel::update(
entity_facts entity_facts
.filter(predicate.eq(target_predicate)) .filter(predicate.eq(target_predicate))
.filter(user_id.eq(*uid)) .filter(user_id.eq(*uid))
@@ -1282,8 +1289,7 @@ impl KnowledgeDao for SqliteKnowledgeDao {
Some(v) => v, Some(v) => v,
None => continue, None => continue,
}; };
for b in (a + 1)..indices.len() { for &ib in &indices[a + 1..] {
let ib = indices[b];
let vb = match &decoded[ib] { let vb = match &decoded[ib] {
Some(v) => v, Some(v) => v,
None => continue, None => continue,
+3 -3
View File
@@ -502,9 +502,9 @@ pub trait ExifDao: Sync + Send {
/// whose calendar position matches the request's span: /// whose calendar position matches the request's span:
/// - `"day"` — same month + day-of-month (any year) /// - `"day"` — same month + day-of-month (any year)
/// - `"week"` — same week-of-year (SQLite `%W`, Monday-anchored — /// - `"week"` — same week-of-year (SQLite `%W`, Monday-anchored —
/// close to but not exactly ISO week 8601; the /// close to but not exactly ISO week 8601; the boundary cases
/// boundary cases at year-start/end can shift by ±1 /// at year-start/end can shift by ±1 vs the prior request-time
/// vs the prior request-time `iso_week()` filter) /// `iso_week()` filter)
/// - `"month"` — same month (any year) /// - `"month"` — same month (any year)
/// ///
/// `tz_offset_minutes` is applied to both sides of the strftime /// `tz_offset_minutes` is applied to both sides of the strftime
+7 -9
View File
@@ -57,30 +57,28 @@ impl ReconcileStats {
/// watcher tick. Errors are logged but never propagated; reconciliation /// watcher tick. Errors are logged but never propagated; reconciliation
/// is best-effort and a transient DB hiccup must not stall the watcher. /// is best-effort and a transient DB hiccup must not stall the watcher.
pub fn run(conn: &mut SqliteConnection) -> ReconcileStats { pub fn run(conn: &mut SqliteConnection) -> ReconcileStats {
let mut stats = ReconcileStats::default(); let stats = ReconcileStats {
tagged_photo_hashes_filled: match backfill_tagged_photo_hashes(conn) {
stats.tagged_photo_hashes_filled = match backfill_tagged_photo_hashes(conn) {
Ok(n) => n, Ok(n) => n,
Err(e) => { Err(e) => {
warn!("reconcile: tagged_photo hash backfill failed: {:?}", e); warn!("reconcile: tagged_photo hash backfill failed: {:?}", e);
0 0
} }
}; },
photo_insights_hashes_filled: match backfill_photo_insights_hashes(conn) {
stats.photo_insights_hashes_filled = match backfill_photo_insights_hashes(conn) {
Ok(n) => n, Ok(n) => n,
Err(e) => { Err(e) => {
warn!("reconcile: photo_insights hash backfill failed: {:?}", e); warn!("reconcile: photo_insights hash backfill failed: {:?}", e);
0 0
} }
}; },
photo_insights_demoted: match collapse_insight_currents(conn) {
stats.photo_insights_demoted = match collapse_insight_currents(conn) {
Ok(n) => n, Ok(n) => n,
Err(e) => { Err(e) => {
warn!("reconcile: photo_insights scalar merge failed: {:?}", e); warn!("reconcile: photo_insights scalar merge failed: {:?}", e);
0 0
} }
},
}; };
if stats.changed() { if stats.changed() {
+4 -1
View File
@@ -2118,7 +2118,10 @@ async fn update_face_handler<D: FaceDao>(
// the short context string we surface in the response body — // the short context string we surface in the response body —
// SQLITE_BUSY here usually means another DAO's writer held the // SQLITE_BUSY here usually means another DAO's writer held the
// lock past `busy_timeout` (5s), which is invisible in `{}`. // lock past `busy_timeout` (5s), which is invisible in `{}`.
warn!("PATCH /image/faces/{}: 500 — update_face failed: {:#}", id, e); warn!(
"PATCH /image/faces/{}: 500 — update_face failed: {:#}",
id, e
);
return HttpResponse::InternalServerError().body(e.to_string()); return HttpResponse::InternalServerError().body(e.to_string());
} }
}; };
+6 -5
View File
@@ -183,15 +183,16 @@ pub async fn get_image(
// review JPEG, ~12 MP). Falls through to NamedFile if no preview is // review JPEG, ~12 MP). Falls through to NamedFile if no preview is
// available, which preserves the historical behavior for callers // available, which preserves the historical behavior for callers
// that genuinely want the original bytes. // that genuinely want the original bytes.
if image_size == PhotoSize::Full && exif::is_tiff_raw(&path) { if image_size == PhotoSize::Full
if let Some(preview) = exif::extract_embedded_jpeg_preview(&path) { && exif::is_tiff_raw(&path)
&& let Some(preview) = exif::extract_embedded_jpeg_preview(&path)
{
span.set_status(Status::Ok); span.set_status(Status::Ok);
return HttpResponse::Ok() return HttpResponse::Ok()
.content_type("image/jpeg") .content_type("image/jpeg")
.insert_header(("Cache-Control", "public, max-age=3600")) .insert_header(("Cache-Control", "public, max-age=3600"))
.body(preview); .body(preview);
} }
}
if let Ok(file) = NamedFile::open(&path) { if let Ok(file) = NamedFile::open(&path) {
span.set_status(Status::Ok); span.set_status(Status::Ok);
@@ -706,7 +707,7 @@ pub async fn set_image_date(
Ok(row) => { Ok(row) => {
span.set_status(Status::Ok); span.set_status(Status::Ok);
HttpResponse::Ok().json(build_metadata_response_for_date_mutation( HttpResponse::Ok().json(build_metadata_response_for_date_mutation(
&library, library,
&normalized_path, &normalized_path,
row, row,
)) ))
@@ -757,7 +758,7 @@ pub async fn clear_image_date(
Ok(row) => { Ok(row) => {
span.set_status(Status::Ok); span.set_status(Status::Ok);
HttpResponse::Ok().json(build_metadata_response_for_date_mutation( HttpResponse::Ok().json(build_metadata_response_for_date_mutation(
&library, library,
&normalized_path, &normalized_path,
row, row,
)) ))
+3 -8
View File
@@ -444,8 +444,7 @@ where
) )
.service(web::resource("/graph").route(web::get().to(get_graph::<D>))) .service(web::resource("/graph").route(web::get().to(get_graph::<D>)))
.service( .service(
web::resource("/predicate-stats") web::resource("/predicate-stats").route(web::get().to(get_predicate_stats::<D>)),
.route(web::get().to(get_predicate_stats::<D>)),
) )
.service( .service(
web::resource("/predicates/{predicate}/bulk-reject") web::resource("/predicates/{predicate}/bulk-reject")
@@ -1261,12 +1260,8 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
let persona = resolve_persona_filter(&req, &claims, &persona_dao); let persona = resolve_persona_filter(&req, &claims, &persona_dao);
let cx = opentelemetry::Context::current(); let cx = opentelemetry::Context::current();
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao"); let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
match dao.bulk_reject_facts_by_predicate( match dao.bulk_reject_facts_by_predicate(&cx, &persona, &predicate, Some(("manual", "manual")))
&cx, {
&persona,
&predicate,
Some(("manual", "manual")),
) {
Ok(rejected) => HttpResponse::Ok().json(BulkRejectResponse { rejected }), Ok(rejected) => HttpResponse::Ok().json(BulkRejectResponse { rejected }),
Err(e) => { Err(e) => {
log::error!("bulk_reject_predicate error: {:?}", e); log::error!("bulk_reject_predicate error: {:?}", e);
+10 -11
View File
@@ -94,7 +94,7 @@ 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(|c: char| matches!(c, ',' | '\n' | '\r')) .split([',', '\n', '\r'])
.map(str::trim) .map(str::trim)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(String::from) .map(String::from)
@@ -148,10 +148,7 @@ pub fn validate_excluded_dirs_entry(entry: &str) -> Result<String, String> {
if let Some(rel) = trimmed.strip_prefix('/') { if let Some(rel) = trimmed.strip_prefix('/') {
// Path form. Reject `..` traversal — `base.join(\"../x\")` doesn't // Path form. Reject `..` traversal — `base.join(\"../x\")` doesn't
// canonicalise, so `path.starts_with(...)` never matches. // canonicalise, so `path.starts_with(...)` never matches.
if rel if rel.split('/').any(|seg| seg == "..") {
.split('/')
.any(|seg| seg == "..")
{
return Err(format!( return Err(format!(
"'{}': '..' segments don't normalise — the prefix-match never fires", "'{}': '..' segments don't normalise — the prefix-match never fires",
trimmed trimmed
@@ -542,7 +539,10 @@ pub async fn patch_library(
{ {
Ok(n) => affected = affected.max(n), Ok(n) => affected = affected.max(n),
Err(e) => { Err(e) => {
warn!("PATCH /libraries/{}: enabled update failed: {:?}", lib_id, e); warn!(
"PATCH /libraries/{}: enabled update failed: {:?}",
lib_id, e
);
return HttpResponse::InternalServerError().body(format!("{}", e)); return HttpResponse::InternalServerError().body(format!("{}", e));
} }
} }
@@ -600,7 +600,9 @@ pub async fn patch_library(
); );
HttpResponse::Ok().json(lib) HttpResponse::Ok().json(lib)
} }
None => HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id)), None => {
HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id))
}
} }
} }
@@ -930,10 +932,7 @@ mod tests {
#[test] #[test]
fn validate_strips_trailing_slash_on_path_entries() { fn validate_strips_trailing_slash_on_path_entries() {
assert_eq!( assert_eq!(validate_excluded_dirs_entry("/photos/").unwrap(), "/photos");
validate_excluded_dirs_entry("/photos/").unwrap(),
"/photos"
);
assert_eq!( assert_eq!(
validate_excluded_dirs_entry("/photos//").unwrap(), validate_excluded_dirs_entry("/photos//").unwrap(),
"/photos" "/photos"