Skip to content

Commit 65787f1

Browse files
authored
Rolling integrity queue, time budgets, bitrot detection (v2.6.58-v2.6.64)
* Rolling integrity queue, time budgets, bitrot detection (v2.6.58-v2.6.62) Integrity checks now sweep the library as a rolling stalest-first queue (batched fetches keyed on last_integrity_check_date) instead of a single all-or-nothing full-table pass, resume after crash or cancellation by construction, and stamp every terminal outcome in bulk. file_changes schedules can carry a per-run time budget (soft deadline, drain on expiry, saturated-tier skip-ahead) so successive runs cover the library in slices. Hash mismatches are now classified: changed mtime = legitimate modification, unchanged mtime = suspected bitrot. Flagged files keep their stored baseline through every scan writer (shared apply_scan_baseline guard), jump the integrity queue, auto-expire after consecutive stable checks plus a clean rescan, and can be manually accepted via POST /api/bitrot/accept. One aggregated high-priority notification fires per run through the new NotificationRule dispatcher. Stored mtimes are written as UTC with a per-row trust flag; untrusted pre-upgrade rows re-baseline on their first check. Removes the dead serial MediaChecker.check_file_changes path and a per-run AsyncResult accumulation leak, and adds a composite index for the queue ordering. * Fix schedule reload: scheduler now syncs from DB directly (v2.6.63) Schedule create/update/delete dispatched reload_schedules_task via Celery, but prefork workers execute tasks in pool children while APScheduler runs only in the scheduler-lock-holding main process. The reload always landed in a child and was skipped, so schedule changes never reached the running scheduler without a container restart. The scheduler process now re-syncs its jobs from the database every 60 seconds, gated on a fingerprint of schedule definitions so jobs are only rebuilt on actual change. next_run is computed at schedule creation so the UI shows the upcoming fire before the first run. * Add rolling-integrity coverage stats to API and dashboard (v2.6.64) Budgeted integrity runs sweep the library in slices, so no single run report answers whether every file has been verified. /api/stats now returns an integrity block: files checked ever and in the last 30 days, coverage percent, never-checked count, oldest check date, and current bitrot-suspected count. The dashboard gains an Integrity Checked stat card with the full breakdown in its tooltip. * Update scan-type and stats API docs for rolling integrity queue
1 parent 98b6548 commit 65787f1

31 files changed

Lines changed: 2226 additions & 248 deletions

CHANGELOG.MD

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,59 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0).
77

8+
## [2.6.64] - 2026-07-09
9+
10+
### Added
11+
12+
- **Rolling-integrity coverage is now visible.** Because budgeted integrity runs sweep the library in slices, no single run report answers "has every file been verified?". `/api/stats` now returns an `integrity` block - files checked ever and within the last 30 days, percent of library covered, never-checked count, the oldest check date (every checked file has been verified at least once since then), and the current bitrot-suspected count. The dashboard gains an "Integrity Checked" stat card showing the coverage percentage, with the full breakdown in its tooltip.
13+
14+
## [2.6.63] - 2026-07-09
15+
16+
### Fixed
17+
18+
- **Created or edited schedules now take effect within a minute instead of requiring a container restart.** Schedule CRUD dispatched `reload_schedules_task` through Celery, but prefork workers execute tasks in pool children while APScheduler runs only in the scheduler-lock-holding main process - the reload always landed in a child, logged "Scheduler not running in this worker", and was skipped, so no schedule change since the mechanism was added ever reached the running scheduler without a restart (verified live: a freshly created `file_changes` schedule never fired at its cron time). The scheduler process now re-syncs its jobs directly from the database every 60 seconds, gated on a fingerprint of the schedule definitions so jobs are only rebuilt when something actually changed. New schedules also get `next_run` computed at creation so the UI shows the upcoming fire immediately instead of after the first run.
19+
20+
## [2.6.62] - 2026-07-09
21+
22+
### Added
23+
24+
- **Bitrot UI.** Results list shows a distinct "Bitrot?" badge (violet) taking precedence over corrupted/warning; a "Bitrot Suspected" filter button joins the corrupted/warning/healthy set (`bitrot_suspected=true` on `/api/scan-results`); the file details modal gains a bitrot section (detection date, baseline vs current hash/mtime/size, stable-check count) with an "Accept Current State" action; the same accept action appears in each flagged file's Actions dropdown (desktop and mobile), confirming before adopting the current content as the new baseline via `POST /api/bitrot/accept`.
25+
26+
## [2.6.61] - 2026-07-09
27+
28+
### Added
29+
30+
- **The integrity check now distinguishes bitrot from legitimate modification.** Previously any hash mismatch was uniformly marked `modified` and rescanned - and the rescan adopted the new content's hash as baseline, silently laundering corruption (a bit flip in non-critical stream data passes decode checks). The hash task now classifies: hash mismatch with CHANGED mtime = `modified` (normal edit/replace, rescans as before); hash mismatch with UNCHANGED mtime (2-second epsilon) = `bitrot_suspected` - no legitimate write path alters content without touching mtime. Suspected files are flagged (`bitrot_suspected` + permanent `bitrot_detected_date`/`bitrot_details`, first detection date preserved across re-detections), one aggregated high-priority `bitrot_suspected` notification fires per run (a dying disk flags files in bulk; per-file dispatch would mean thousands of serial HTTP round-trips), the file is rescanned for decodability, and critically NO scan path overwrites the stored hash/mtime baseline for flagged files: the guard lives in a single shared writer (`apply_scan_baseline`) used by both the serial scan save and the parallel chunk workers that perform all normal-scan and rescan persistence.
31+
- **Auto-expire state machine for flagged files.** Flagged files jump the integrity queue. Each subsequent check compares against the candidate hash: stable content for `BITROT_STABLE_CHECKS_TO_EXPIRE` consecutive checks (default 2) plus a clean rescan auto-adopts the content as the new baseline and clears the flag (detection record kept permanently - a per-disk history of "files that ever tripped bitrot" is a dying-disk signal). Content matching the ORIGINAL baseline again = transient read anomaly, flag clears. A THIRD hash = active rot: counter resets, re-notification fires. Manual override: `POST /api/bitrot/accept` (file_ids) adopts the current state immediately via the same shared transition as auto-expire; the adopted baseline mtime is the one recorded with the candidate hash, not a fresh stat, so the stored hash/mtime pair always describes content that actually existed. Stale queued results for a row whose flag was cleared mid-run (accept races Phase 3 on budgeted runs) are ignored instead of resurrecting state-machine fields.
32+
- **Notification rules now actually fire.** `NotificationRule`/`NotificationProvider` existed as CRUD-only models - no backend code ever evaluated rules. New `dispatch_event()` in the notification service joins active rules to active providers, delivers, and records per-provider status. `bitrot_suspected` is the first wired event type.
33+
- `bitrot_suspected` filter on `/api/scan-results`; bitrot columns in CSV/JSON exports and `ScanResult.to_dict`.
34+
35+
### Fixed
36+
37+
- **`get_file_info` stored naive local mtimes; all baselines are now UTC.** `datetime.fromtimestamp(st_mtime)` without a timezone poisoned every stored `last_modified` on hosts where TZ != UTC (its error fallback even stored aware UTC, mixing both semantics in one column). Bitrot classification requires trusted baselines, so a new `mtime_baseline_utc` flag (false for all pre-upgrade rows) gates it: untrusted rows whose hash mismatches classify as `modified` and re-baseline via rescan, and untrusted rows whose hash MATCHES (the cold-archive common case) re-baseline directly during the integrity check - no false bitrot alarms from the old data, detection reliable from each file's second check. The scan-result cache validity check now treats a content-hash match as sufficient (mtime equality only as fallback for hash-less rows), so the UTC change cannot force a one-time full-library re-decode on hosts where TZ is not UTC. Scan error paths no longer fabricate a scan-time mtime for unreadable files; they leave the stored baseline untouched.
38+
39+
## [2.6.60] - 2026-07-09
40+
41+
### Added
42+
43+
- **Integrity check schedules can now carry a time budget (`time_budget_minutes`).** A budgeted run dispatches hash tasks until the soft deadline expires, drains in-flight tasks (which still stamp `last_integrity_check_date`), and completes with a coverage message ("Budget reached: N of M files verified this run"). Because the rolling queue (2.6.59) is ordered stalest-first, successive budgeted runs sweep the whole library in disjoint slices - a 50-hour full pass becomes ~100 min/day for a monthly cadence - without any single run monopolizing IO. NULL budget = unlimited (current behavior); the field is valid only on `file_changes` schedules and manual runs stay unlimited unless the start request passes `time_budget_minutes` explicitly. Budgeted runs dispatch largest files first within each batch so a 70GB file is not started at minute 59; dispatch skips past saturated size tiers instead of head-of-line blocking, so a run of large files at the front of a batch cannot cap the whole window at the huge-file concurrency limit while small-file slots idle. The budget is resolved inside the service (not the HTTP route), so every entry point honors a schedule's configured budget. Schedule create/edit forms expose the field with sizing guidance.
44+
45+
## [2.6.59] - 2026-07-09
46+
47+
### Changed
48+
49+
- **The integrity check (file changes) is now a rolling queue instead of a single full-table pass.** The producer previously loaded every `scan_results` row into memory in one query (hundreds of MB at 1M+ files) and lost all progress on crash or cancellation. It now re-fetches the queue in ordered batches (`INTEGRITY_BATCH_SIZE`, default 10,000): never-checked files first (`last_integrity_check_date` NULL), then stalest-first. `last_integrity_check_date` - previously written but never read - is now the queue cursor: every terminal outcome (result received, hash error after retries, abandoned task) stamps the file, pushing it to the back of the ordering, so an interrupted run resumes where it left off by construction. In-flight tasks, rows whose timestamp write failed, and rows whose task dispatch failed (broker errors) are excluded from re-fetches explicitly - ordering alone would re-hash the first two and re-fail the third forever; failure counts are surfaced in the completion message. Stamps are written in bulk (one UPDATE per batch fetch instead of one SELECT plus UPDATE per file), a queue re-fetch only runs after a dispatch, completion, or exclusion changed the queue (no tight query loops when every size tier is saturated or the broker is down), and a successfully hashed file gets `file_exists` restored, recovering rows stranded by a past mount outage. A composite index supporting the queue ordering ships in the same migration.
50+
51+
### Fixed
52+
53+
- **Producer no longer accumulates one AsyncResult handle per dispatched task.** `task_results` collected every dispatched task's result handle for the lifetime of the run and was never read - a per-run memory leak of 1M+ objects on large libraries. Removed.
54+
55+
## [2.6.58] - 2026-07-09
56+
57+
### Removed
58+
59+
- **Legacy serial `MediaChecker.check_file_changes` deleted.** It had zero callers (all file-changes routes go through `MaintenanceService`'s Celery-based path) and was a divergent second implementation: it only hashed a file when its mtime differed, so it structurally could not detect bitrot (content change with unchanged mtime); it compared naive local timestamps; and it ran serially with no progress reporting or cancellation. Removing it ahead of the bitrot classification work ensures the integrity-check logic exists in exactly one place.
60+
861
## [2.6.57] - 2026-06-11
962

1063
### Fixed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ PixelProbe detects corrupted video, image, and audio files across your media lib
2020
- FFmpeg-based deep video analysis
2121
- Video freeze detection (stuck frames while audio continues) via FFmpeg freezedetect filter with black frame false positive filtering
2222
- ImageMagick and PIL image validation
23+
- Bitrot detection: a content hash change without a matching mtime change flags the file for review instead of silently adopting the new hash
2324
- Smart warning system for minor issues vs critical corruption
2425
- Multi-stage detection with configurable thresholds
2526
- Automatic retry logic for transient failures
@@ -28,6 +29,7 @@ PixelProbe detects corrupted video, image, and audio files across your media lib
2829
- **Parallel multi-threaded scanning**: Configurable worker threads (10-24 workers recommended) with thread-safe database access
2930
- **Real-time progress**: Live updates with ETA calculations and phase tracking
3031
- **Multiple scan types**: Full scan, cleanup, integrity check
32+
- **Rolling integrity queue**: Integrity checks sweep the library stalest-first in batches and resume where they left off; schedules can carry a per-run time budget so no single run monopolizes IO
3133
- **Scheduled automated scans**: Cron expressions or simple intervals for hands-free monitoring
3234
- **Smart exclusions**: Configure paths and file extensions to skip
3335
- **Phase-based scanning**: Discovery → Database → Validation workflow
@@ -342,8 +344,9 @@ PixelProbe provides a REST API with OpenAPI/Swagger documentation.
342344
#### Maintenance Endpoints
343345
- `POST /api/cleanup` - Remove orphaned database entries
344346
- `GET /api/cleanup-status` - Get cleanup operation status
345-
- `POST /api/file-changes` - Detect file system changes
347+
- `POST /api/file-changes` - Detect file system changes (optional `time_budget_minutes`)
346348
- `GET /api/file-changes-status` - Get file changes scan status
349+
- `POST /api/bitrot/accept` - Accept a bitrot-suspected file's current content as the new baseline
347350
- `POST /api/reset-for-rescan` - Reset files for rescanning
348351
- `POST /api/reset-files-by-path` - Reset specific files by path
349352

docs/api/SCAN_TYPES_DOCUMENTATION.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -95,27 +95,27 @@ POST /api/force-scan-pending
9595

9696
### 4. File Changes Scan (`file_changes`)
9797
**Endpoint**: `GET/POST /api/file-changes`
98-
**Purpose**: Detect and scan modified files
98+
**Purpose**: Verify stored content hashes and classify what changed (rolling integrity queue)
9999
**When to use**:
100-
- Regular maintenance scans
101-
- After media file edits
102-
- To catch file corruption over time
100+
- Scheduled integrity sweeps (pair with a per-schedule time budget)
101+
- After bulk media edits
102+
- To catch silent corruption (bitrot) over time
103103

104104
**How it works**:
105-
1. **Phase 1 - Integrity Check**: Compares file modification times with database
106-
2. **Phase 2 - Scan Changed**: Re-scans files that have been modified
107-
3. Updates database with new scan results
105+
1. Pulls files from a rolling queue ordered stalest-first by `last_integrity_check_date`; files flagged as suspected bitrot jump the queue
106+
2. Re-hashes each file and classifies the result: hash match = unchanged; hash and mtime both changed = modified (queued for rescan); hash changed while mtime did not = suspected bitrot (flagged and notified, stored baseline preserved)
107+
3. Stamps every processed file, so interrupted or budget-limited runs resume where they left off
108108

109109
**Features**:
110-
- Detects file modifications via timestamps
111-
- Optionally detects file size changes
112-
- Re-scans only changed files
113-
- Reports list of changed files
114-
- Efficient for regular checks
110+
- Optional `time_budget_minutes` (request body, or the schedule's field): dispatch stops at the deadline, in-flight hashes drain, and the queue resumes at the next run
111+
- Bitrot flags auto-expire after consecutive stable checks plus a clean rescan; accept manually via `POST /api/bitrot/accept`
112+
- Cumulative coverage exposed in `/api/stats` (`integrity` block) and the dashboard's Integrity Checked card
113+
- Reports the changed-file list with hash, mtime, and size detail
115114

116115
**Example**:
117116
```json
118117
POST /api/file-changes
118+
{"time_budget_minutes": 10}
119119
```
120120

121121
### 5. Cleanup (`cleanup`)

openapi.yaml

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,13 @@ paths:
674674
type: string
675675
enum: [all, 'true', 'false']
676676
default: all
677+
- name: bitrot_suspected
678+
in: query
679+
description: Filter files flagged as suspected bitrot (hash changed, mtime unchanged)
680+
schema:
681+
type: string
682+
enum: [all, 'true', 'false']
683+
default: all
677684
- name: search
678685
in: query
679686
schema:
@@ -1259,7 +1266,12 @@ paths:
12591266
get:
12601267
summary: Get statistics summary
12611268
description: |
1262-
Retrieve comprehensive statistics about scanned files and corruption.
1269+
Retrieve statistics about scanned files and corruption. The response
1270+
includes an `integrity` block with rolling-coverage numbers: files
1271+
integrity-checked ever and within the last 30 days, coverage percent,
1272+
never-checked count, the oldest check date (every checked file has
1273+
been verified at least once since then), and the current
1274+
bitrot-suspected count.
12631275
12641276
**Example:**
12651277
```bash
@@ -1522,6 +1534,41 @@ paths:
15221534
'200':
15231535
description: Files marked as good successfully
15241536

1537+
/bitrot/accept:
1538+
post:
1539+
summary: Accept current state of bitrot-suspected files
1540+
description: |
1541+
Adopt each flagged file's current content as the new integrity
1542+
baseline (the candidate hash plus the mtime recorded when that hash
1543+
was computed), clearing the bitrot_suspected flag and stability
1544+
counter. The detection record (bitrot_detected_date, bitrot_details)
1545+
is preserved permanently. Files that are not flagged are skipped and
1546+
reported back.
1547+
1548+
**Example:**
1549+
```bash
1550+
curl -X POST http://your-server/api/bitrot/accept \
1551+
-H "Authorization: Bearer YOUR_TOKEN" \
1552+
-H "Content-Type: application/json" \
1553+
-d '{"file_ids": [123, 456]}'
1554+
```
1555+
tags: [Maintenance]
1556+
requestBody:
1557+
required: true
1558+
content:
1559+
application/json:
1560+
schema:
1561+
type: object
1562+
required: [file_ids]
1563+
properties:
1564+
file_ids:
1565+
type: array
1566+
items:
1567+
type: integer
1568+
responses:
1569+
'200':
1570+
description: Accepted; response lists accepted count and skipped ids
1571+
15251572
/schedules:
15261573
get:
15271574
summary: List scheduled scans
@@ -1580,6 +1627,14 @@ paths:
15801627
force_rescan:
15811628
type: boolean
15821629
default: false
1630+
time_budget_minutes:
1631+
type: integer
1632+
nullable: true
1633+
minimum: 1
1634+
description: |
1635+
file_changes schedules only. Soft per-run deadline in
1636+
minutes; dispatch stops when it expires and the rolling
1637+
integrity queue resumes at the next run. Null = unlimited.
15831638
responses:
15841639
'201':
15851640
description: Schedule created successfully
@@ -2423,9 +2478,19 @@ paths:
24232478
type: array
24242479
items:
24252480
type: string
2481+
time_budget_minutes:
2482+
type: integer
2483+
minimum: 1
2484+
description: |
2485+
Optional soft deadline in minutes. Dispatch stops when it
2486+
expires; in-flight hashes drain and the rolling queue
2487+
resumes at the next run. Omitted = unlimited (scheduled
2488+
runs inherit their schedule's budget).
24262489
responses:
24272490
'200':
24282491
description: File changes check started
2492+
'400':
2493+
description: Invalid time_budget_minutes
24292494
'409':
24302495
description: Check already in progress
24312496

0 commit comments

Comments
 (0)