Skip to content

feat(worker): Camera-labelled bursts through a BurstDetection work item (#297) - #300

Merged
bitbiter-dev merged 4 commits into
masterfrom
feat/297-camera-bursts
Oct 23, 2026
Merged

bitbiter-dev merged 4 commits into
masterfrom
feat/297-camera-bursts

Conversation

@bitbiter-dev

Copy link
Copy Markdown
Owner

Closes #297. Design: note on #296, decision (1A + 2A, 3B, cover SET NULL).

Summary

After a crawl, photos of one storage config sharing Apple's BurstUUID form one Burst; its stored Cover is the earliest member (GLOSSARY.md).

 CrawlHandler.RunAsync
   ActiveStorageConfigs.FindAsync        # shared skip guard (Crawl + BurstDetection)
   pipeline.RunAsync
   ReportDuplicatesAsync
+  queue.EnqueueAsync(BurstDetectionPayload)   # dedup key burst-detection:{id}

 WorkScheduler
-  CrawlSchedules
+  StorageConfigSchedules<CrawlPayload>
+  StorageConfigSchedules<BurstDetectionPayload>   # fallback, crawl interval
BurstDetectionHandler.RunAsync(config)
  skip if config missing/inactive; fail if root missing (nothing marked)
  repair every burst of the config (and memberless ones)
    < 2 active members -> delete burst
    else               -> cover = earliest active member
  for each unchecked Image/LivePhoto, oldest first, in batches of 100
    read original once (EXIF capture time, BurstUUID)  -> BurstCheck row
    if candidate (EXIF time, make, model, thumbnail) and labelled
      join every checked candidate with that label: create / join / merge into oldest burst
      cover = earliest member
    save photo + burst changes together
src/Anichron.Core/
├── Domain/BurstCheck.cs                    # new table, cascades with its asset
├── Domain/Burst.cs                         # PrimaryAssetId nullable, SET NULL
├── Data/Repository/BurstRepository.cs      # every burst query + the checked mark
└── Work/Payloads.cs                        # BurstDetectionPayload, IStorageConfigPayload<TSelf>
src/Anichron.Worker/
├── Bursts/BurstDetection.cs                # handler
├── Bursts/OriginalPhotoReader.cs           # MetadataExtractor: DateTimeOriginal, Apple BurstUUID
├── Work/StorageConfigSchedules.cs          # 3B, replaces CrawlSchedules
└── Work/ActiveStorageConfigs.cs            # shared skip guard, StorageConfigSkipped
  • ContentChangedWriter clears the mark (file edited in place is checked again); a moved file keeps mark and burst.
  • WorkSchedule<TPayload> is no longer abstract; CrawlSkipped became StorageConfigSkipped (same JSON shape).
  • ExifDate.Parse is shared by ExifExtractionMiddleware and the reader.

Beyond the note: a missing root fails the run before any photo is marked, so an unmounted share cannot mark the library as non-candidates. Memberless bursts are loaded and deleted (not one statement); whichever config's run finds one counts it as dissolved.

Evidence

  • Gate: MetadataExtractor 2.9.3 on the maintainer's IMG_4386/4387.HEIC yields AppleMakernoteDirectory (no BurstUUID in either), so the label read holds.
  • Before: deleting a storage config holding a burst failed on FK_Bursts_MediaAssets_PrimaryAssetId (RESTRICT).
    After: BurstDeleteBehaviourTests (integration): config delete cascades assets and checks, burst stays coverless; deleting the cover nulls it and keeps the other member.
  • Handler at the seam (BurstDetectionTests, 38 cases), e.g.
    a(12:02), b(12:01), c(12:03) labelled X  -> {checked 3, created 1}, cover b
    stored {a,b} + {c,d} labelled X, new e   -> {checked 1, merged 1}, all in older burst
    burst with cover soft-deleted            -> cover = earliest active member
    burst left with one active member        -> {dissolved 1}, BurstId cleared
    
  • Unit suites pass (pre-push), integration suite passes (58), scripts/mutation-changed.sh 100.00 %. A throwaway run of the handler on real PostgreSQL confirmed the queries translate and dissolve via the tracked SET NULL fix-up.

Merge Danger

Door: one-way

Migration BurstDetection adds BurstChecks and changes Bursts.PrimaryAssetId to nullable SET NULL; the Burst query filter now keeps coverless bursts. Rows written by detection persist.

Blast Radius: Worker

First run per config after deployment reads every photo's original once (cheaper than a crawl, which hashes every file). Crawl results now return StorageConfigSkipped instead of CrawlSkipped (same { reason } shape). No API change.

🤖 Generated with Claude Code

bitbiter-dev and others added 4 commits October 23, 2026 20:52
…em (#297)

After a crawl, the photos of a storage config that share Apple's BurstUUID
form one burst, with the earliest member as the stored cover.

- BurstDetection work kind, one item per storage config with dedup key
  burst-detection:{id}. The config's Crawl enqueues it once the crawl ran;
  StorageConfigSchedules<TPayload>, now shared with Crawl (decision 3B),
  schedules it at the crawl interval as a fallback.
- BurstChecks table: one row per checked photo (candidate flag, BurstUUID).
  Detection reads each photo's original once (1A + 2A); the first run
  checks the whole library. A missing root fails the item before any
  photo is marked, so an unmounted share cannot mark photos as
  non-candidates.
- Each run repairs the config's bursts first (fewer than two active
  members: deleted; otherwise the earliest active member becomes cover;
  memberless bursts deleted), then checks unchecked Image and LivePhoto
  rows oldest first, in batches, joining or merging bursts by label.
- Bursts.PrimaryAssetId is nullable with ON DELETE SET NULL instead of
  RESTRICT, so deleting a storage config or a cover no longer fails; the
  Burst query filter keeps coverless bursts visible.
- ContentChangedWriter clears the burst mark of a file edited in place.
- MetadataExtractor 2.9.3 yields an AppleMakernoteDirectory from the
  maintainer's sample HEICs (no BurstUUID in them), confirming the label
  read before building on it.
- ExifDate.Parse is shared by the EXIF middleware and the reader; it
  relies on NodaTime returning a failed result for null text.
- GLOSSARY.md with Burst and Cover, from docs/26-burst-design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#297)

The members are moved after the merged bursts are removed because removing
a burst sets its tracked members' BurstId to null immediately; the merge
tests pin that order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nfig guard (#297)

- IBurstRepository (Core) holds every burst query and the burst-checked
  mark, so the handler no longer touches AnichronDbContext and BurstChecks
  is written through one route (ContentChangedWriter clears the mark
  through it too). The includes for a burst's members live in one place.
- ActiveStorageConfigs is the one "missing or inactive config" guard for
  per-config handlers; Crawl and BurstDetection both return
  StorageConfigSkipped. CrawlSkipped is gone: same JSON shape, one type.
- BurstDetectionResult is the run's counter itself; the private Tally
  that mirrored it is gone.
- WorkSchedule<TPayload> is no longer abstract, so StorageConfigSchedules
  builds it directly and the empty StorageConfigSchedule subclass is gone.
- ExifDate.Parse handles a missing value explicitly instead of relying on
  NodaTime returning a failed parse for null; tests pin null, empty and
  ISO input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bitbiter-dev
bitbiter-dev merged commit 8547e1d into master Oct 23, 2026
20 of 23 checks passed
@bitbiter-dev
bitbiter-dev deleted the feat/297-camera-bursts branch October 23, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(worker): Camera-labelled bursts — BurstDetection work item, checked mark and repair

1 participant