Skip to content

Commit fee9c28

Browse files
committed
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.
1 parent 98b6548 commit fee9c28

26 files changed

Lines changed: 1977 additions & 232 deletions

CHANGELOG.MD

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,47 @@ 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.62] - 2026-07-09
9+
10+
### Added
11+
12+
- **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`.
13+
14+
## [2.6.61] - 2026-07-09
15+
16+
### Added
17+
18+
- **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.
19+
- **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.
20+
- **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.
21+
- `bitrot_suspected` filter on `/api/scan-results`; bitrot columns in CSV/JSON exports and `ScanResult.to_dict`.
22+
23+
### Fixed
24+
25+
- **`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.
26+
27+
## [2.6.60] - 2026-07-09
28+
29+
### Added
30+
31+
- **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.
32+
33+
## [2.6.59] - 2026-07-09
34+
35+
### Changed
36+
37+
- **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.
38+
39+
### Fixed
40+
41+
- **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.
42+
43+
## [2.6.58] - 2026-07-09
44+
45+
### Removed
46+
47+
- **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.
48+
849
## [2.6.57] - 2026-06-11
950

1051
### 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

openapi.yaml

Lines changed: 60 additions & 0 deletions
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:
@@ -1522,6 +1529,41 @@ paths:
15221529
'200':
15231530
description: Files marked as good successfully
15241531

1532+
/bitrot/accept:
1533+
post:
1534+
summary: Accept current state of bitrot-suspected files
1535+
description: |
1536+
Adopt each flagged file's current content as the new integrity
1537+
baseline (the candidate hash plus the mtime recorded when that hash
1538+
was computed), clearing the bitrot_suspected flag and stability
1539+
counter. The detection record (bitrot_detected_date, bitrot_details)
1540+
is preserved permanently. Files that are not flagged are skipped and
1541+
reported back.
1542+
1543+
**Example:**
1544+
```bash
1545+
curl -X POST http://your-server/api/bitrot/accept \
1546+
-H "Authorization: Bearer YOUR_TOKEN" \
1547+
-H "Content-Type: application/json" \
1548+
-d '{"file_ids": [123, 456]}'
1549+
```
1550+
tags: [Maintenance]
1551+
requestBody:
1552+
required: true
1553+
content:
1554+
application/json:
1555+
schema:
1556+
type: object
1557+
required: [file_ids]
1558+
properties:
1559+
file_ids:
1560+
type: array
1561+
items:
1562+
type: integer
1563+
responses:
1564+
'200':
1565+
description: Accepted; response lists accepted count and skipped ids
1566+
15251567
/schedules:
15261568
get:
15271569
summary: List scheduled scans
@@ -1580,6 +1622,14 @@ paths:
15801622
force_rescan:
15811623
type: boolean
15821624
default: false
1625+
time_budget_minutes:
1626+
type: integer
1627+
nullable: true
1628+
minimum: 1
1629+
description: |
1630+
file_changes schedules only. Soft per-run deadline in
1631+
minutes; dispatch stops when it expires and the rolling
1632+
integrity queue resumes at the next run. Null = unlimited.
15831633
responses:
15841634
'201':
15851635
description: Schedule created successfully
@@ -2423,9 +2473,19 @@ paths:
24232473
type: array
24242474
items:
24252475
type: string
2476+
time_budget_minutes:
2477+
type: integer
2478+
minimum: 1
2479+
description: |
2480+
Optional soft deadline in minutes. Dispatch stops when it
2481+
expires; in-flight hashes drain and the rolling queue
2482+
resumes at the next run. Omitted = unlimited (scheduled
2483+
runs inherit their schedule's budget).
24262484
responses:
24272485
'200':
24282486
description: File changes check started
2487+
'400':
2488+
description: Invalid time_budget_minutes
24292489
'409':
24302490
description: Check already in progress
24312491

0 commit comments

Comments
 (0)