Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions CLAUDE.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Anichron

A self-hosted photo library that resurfaces a user's own photos and videos from their NAS as daily flashbacks.

## Language

**Burst**:
Photos of the same scene from one storage config, taken moments apart, shown as one memory. Either the camera marked them as one burst, or each one looks like a neighbouring frame taken shortly before or after it.
_Avoid_: Moment, similar group, near-duplicates

**Cover**:
The member of a burst that represents it wherever the burst is shown as one memory: the earliest member the viewing user has not hidden.
_Avoid_: Primary photo, key photo
26 changes: 22 additions & 4 deletions docs/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,26 @@ COMMENT ON COLUMN "WorkItems"."ProgressNote" IS 'The latest progress snapshot (d
COMMENT ON COLUMN "WorkItems"."Result" IS 'The handler''s results summary, written when the item succeeds, or when it is cancelled after its handler returned one; a Cancelled item''s results are partial.';


CREATE TABLE "BurstChecks" (
"AssetId" uuid NOT NULL,
"CheckedAt" timestamp with time zone NOT NULL,
"IsCandidate" boolean NOT NULL,
"CameraBurstId" text,
CONSTRAINT "PK_BurstChecks" PRIMARY KEY ("AssetId")
);
COMMENT ON TABLE "BurstChecks" IS 'One row per photo burst detection has checked; no row means unchecked. Owned by the BurstDetection work item, which reads the original once when it checks a photo. A file edited in place loses its row, so it is checked again.';
COMMENT ON COLUMN "BurstChecks"."IsCandidate" IS 'The photo has an EXIF capture time, a camera make and model, and a thumbnail; only candidates are grouped. False also when the original could not be read.';
COMMENT ON COLUMN "BurstChecks"."CameraBurstId" IS 'Apple''s BurstUUID from the original''s maker note: photos of one storage config sharing it form one burst. Null when the camera wrote none.';


CREATE TABLE "Bursts" (
"Id" uuid NOT NULL,
"PrimaryAssetId" uuid NOT NULL,
"PrimaryAssetId" uuid,
"CreatedAt" timestamp with time zone NOT NULL,
CONSTRAINT "PK_Bursts" PRIMARY KEY ("Id")
);
COMMENT ON TABLE "Bursts" IS 'Groups a rapid-fire sequence so a flashback shows one memory rather than nine near-identical frames. The table and schema exist; burst DETECTION is not yet implemented in the Worker, so in a running system this table is empty.';
COMMENT ON COLUMN "Bursts"."PrimaryAssetId" IS 'The frame chosen to represent the burst. Restrict on delete: losing the cover would leave a group that cannot be rendered.';
COMMENT ON TABLE "Bursts" IS 'Groups photos of one storage config taken moments apart, so a flashback shows one memory rather than nine near-identical frames. Written only by the Worker''s BurstDetection work item, which also repairs bursts: one left with fewer than two active members is deleted, and so is one with no members at all.';
COMMENT ON COLUMN "Bursts"."PrimaryAssetId" IS 'The stored cover, the earliest member. SetNull on delete, so deleting the cover''s row (a storage config removal cascading to its assets) cannot fail on the burst; the next BurstDetection run picks a new cover or deletes the burst.';


CREATE TABLE "MediaAssets" (
Expand Down Expand Up @@ -192,6 +204,9 @@ COMMENT ON COLUMN "ProxyFiles"."ProxyPath" IS 'Path relative to the proxy root,
COMMENT ON COLUMN "ProxyFiles"."ProxyType" IS 'Thumbnail, WebVideo or FullPreview, persisted as text. The BlurHash placeholder is not a proxy: it is the Metadata.Blurhash column.';


CREATE INDEX "IX_BurstChecks_CameraBurstId" ON "BurstChecks" ("CameraBurstId");


CREATE INDEX "IX_Bursts_PrimaryAssetId" ON "Bursts" ("PrimaryAssetId");


Expand Down Expand Up @@ -267,4 +282,7 @@ CREATE INDEX "IX_WorkItems_StorageConfigId" ON "WorkItems" ("StorageConfigId");
CREATE INDEX "IX_WorkItems_UpdatedAt" ON "WorkItems" ("UpdatedAt");


ALTER TABLE "Bursts" ADD CONSTRAINT "FK_Bursts_MediaAssets_PrimaryAssetId" FOREIGN KEY ("PrimaryAssetId") REFERENCES "MediaAssets" ("Id") ON DELETE RESTRICT;
ALTER TABLE "BurstChecks" ADD CONSTRAINT "FK_BurstChecks_MediaAssets_AssetId" FOREIGN KEY ("AssetId") REFERENCES "MediaAssets" ("Id") ON DELETE CASCADE;


ALTER TABLE "Bursts" ADD CONSTRAINT "FK_Bursts_MediaAssets_PrimaryAssetId" FOREIGN KEY ("PrimaryAssetId") REFERENCES "MediaAssets" ("Id") ON DELETE SET NULL;
16 changes: 15 additions & 1 deletion src/Anichron.Core.Tests.Unit/Data/ModelInvariantsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,24 @@ public void QueryFilters_AreSoftDeleteOnly()
.ToList();

filters.Select(filter => filter.Entity).Should().BeEquivalentTo(
[nameof(MediaAsset), nameof(Metadata), nameof(ProxyFile), nameof(AssetInteraction), nameof(Burst)],
[nameof(MediaAsset), nameof(Metadata), nameof(ProxyFile), nameof(AssetInteraction), nameof(Burst), nameof(BurstCheck)],
"soft-delete is the only global filter: on the asset and on what hangs off it, one filter each");
filters.Should().AllSatisfy(filter => filter.Expression.Should().Contain(
nameof(MediaAsset.IsSoftDeleted),
"per-user rules (hidden, ownership) stay query-level, because the Worker shares the context and has no user"));
}

[Fact]
public void BurstCover_IsSetToNullWhenItsAssetIsDeleted()
{
using var context = AnichronDbContexts.NpgsqlModel();
var burst = context.Model.FindEntityType(typeof(Burst))!;

var cover = burst.GetForeignKeys().Should().ContainSingle(
"a burst points at one asset, its cover; the members point at the burst").Which;
cover.Properties.Select(p => p.Name).Should().Equal(nameof(Burst.PrimaryAssetId));
cover.IsRequired.Should().BeFalse();
cover.DeleteBehavior.Should().Be(DeleteBehavior.SetNull,
"a storage config's removal cascades to its assets and must not fail on a burst's cover");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Anichron.Core.Data.Repository;
using Anichron.Core.Domain;
using Anichron.Testing.Data;
using Microsoft.EntityFrameworkCore;

namespace Anichron.Core.Tests.Unit.Data.Repository;

public sealed class BurstRepositoryTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;

[Fact]
public async Task RemoveCheckAsync_CheckedPhoto_DeletesItsRowAtTheNextSaveAsync()
{
var name = Guid.NewGuid().ToString();
var asset = new MediaAsset { Id = Guid.NewGuid(), StorageConfigId = Guid.NewGuid(), FilePath = "a.jpg", FileName = "a.jpg", ContentHash = "cafe" };
await using (var seed = AnichronDbContexts.InMemory(name))
{
seed.MediaAssets.Add(asset);
seed.BurstChecks.Add(new BurstCheck { AssetId = asset.Id, IsCandidate = true });
await seed.SaveChangesAsync(Ct);
}

await using var db = AnichronDbContexts.InMemory(name);
await new EfBurstRepository(db).RemoveCheckAsync(asset.Id, Ct);
await db.SaveChangesAsync(Ct);

(await db.BurstChecks.CountAsync(Ct)).Should().Be(0);
}

[Fact]
public async Task RemoveCheckAsync_UncheckedPhoto_ChangesNothingAsync()
{
await using var db = AnichronDbContexts.InMemory();

await new EfBurstRepository(db).RemoveCheckAsync(Guid.NewGuid(), Ct);

db.ChangeTracker.HasChanges().Should().BeFalse();
}
}
11 changes: 10 additions & 1 deletion src/Anichron.Core.Tests.Unit/Data/SoftDeleteQueryFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public sealed class SoftDeleteQueryFilterTests : IAsyncLifetime

private readonly MediaAsset active = Asset("active.jpg", isSoftDeleted: false);
private readonly MediaAsset deleted = Asset("deleted.jpg", isSoftDeleted: true);
private readonly Guid coverless = Guid.NewGuid();
private readonly User user = new() { Id = Guid.NewGuid(), Username = "alice", Email = "a@example.com", PasswordHash = "h" };

private static MediaAsset Asset(string name, bool isSoftDeleted)
Expand All @@ -36,7 +37,10 @@ private void Seed()
db.ProxyFiles.Add(new ProxyFile { Id = Guid.NewGuid(), AssetId = asset.Id, ProxyPath = asset.FileName });
db.Interactions.Add(new AssetInteraction { Id = Guid.NewGuid(), UserId = user.Id, AssetId = asset.Id });
db.Bursts.Add(new Burst { Id = Guid.NewGuid(), PrimaryAssetId = asset.Id });
db.BurstChecks.Add(new BurstCheck { AssetId = asset.Id });
}

db.Bursts.Add(new Burst { Id = coverless, PrimaryAssetId = null });
}

public async ValueTask DisposeAsync() => await db.DisposeAsync();
Expand All @@ -62,8 +66,13 @@ public async Task Interactions_OnSoftDeletedAssetAreHiddenAsync()
.Should().Equal(active.Id);

[Fact]
public async Task Bursts_WhosePrimaryAssetIsSoftDeletedAreHiddenAsync()
public async Task Bursts_WhosePrimaryAssetIsSoftDeletedAreHiddenButCoverlessOnesAreNotAsync()
=> (await db.Bursts.Select(b => b.PrimaryAssetId).ToListAsync(TestContext.Current.CancellationToken))
.Should().BeEquivalentTo([active.Id, (Guid?)null]);

[Fact]
public async Task BurstChecks_OfSoftDeletedAssetAreHiddenAsync()
=> (await db.BurstChecks.Select(c => c.AssetId).ToListAsync(TestContext.Current.CancellationToken))
.Should().Equal(active.Id);

[Fact]
Expand Down
23 changes: 23 additions & 0 deletions src/Anichron.Core.Tests.Unit/Work/PayloadsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,29 @@ public void Crawl_IsOnePeriodicItemPerConfigScopedToIt()
ScopeOf(new CrawlPayload(configId)).Should().Be(configId);
}

[Fact]
public void BurstDetection_IsOnePeriodicItemPerConfigScopedToIt()
{
var configId = Guid.Parse("77777777-7777-7777-7777-777777777777");

Describe(new BurstDetectionPayload(configId)).Should().Be(
(WorkKind.BurstDetection, "burst-detection:77777777-7777-7777-7777-777777777777"));
ScopeOf(new BurstDetectionPayload(configId)).Should().Be(configId);
}

[Fact]
public void PerConfigPayloads_AreBuiltForTheirConfig()
{
var configId = Guid.Parse("77777777-7777-7777-7777-777777777777");

(For<CrawlPayload>(configId), For<BurstDetectionPayload>(configId)).Should().Be(
(new CrawlPayload(configId), new BurstDetectionPayload(configId)));
}

private static TPayload For<TPayload>(Guid configId)
where TPayload : IStorageConfigPayload<TPayload>
=> TPayload.For(configId);

[Fact]
public void MaintenancePayloads_AreUnscoped()
=> (ScopeOf(new TokenCleanupPayload()), ScopeOf(new WorkItemRetentionPayload())).Should().Be((null, null));
Expand Down
41 changes: 33 additions & 8 deletions src/Anichron.Core/Data/AnichronDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class AnichronDbContext(DbContextOptions<AnichronDbContext> options) : Db
public DbSet<Metadata> Metadata => Set<Metadata>();
public DbSet<ProxyFile> ProxyFiles => Set<ProxyFile>();
public DbSet<Burst> Bursts => Set<Burst>();
public DbSet<BurstCheck> BurstChecks => Set<BurstCheck>();
public DbSet<AssetInteraction> Interactions => Set<AssetInteraction>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<Invite> Invites => Set<Invite>();
Expand Down Expand Up @@ -57,20 +58,44 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<Burst>(entity =>
{
entity.ToTable(t => t.HasComment(
"Groups a rapid-fire sequence so a flashback shows one memory rather than nine "
+ "near-identical frames. The table and schema exist; burst DETECTION is not yet "
+ "implemented in the Worker, so in a running system this table is empty."
"Groups photos of one storage config taken moments apart, so a flashback shows one "
+ "memory rather than nine near-identical frames. Written only by the Worker's "
+ "BurstDetection work item, which also repairs bursts: one left with fewer than two "
+ "active members is deleted, and so is one with no members at all."
));
entity.Property(b => b.PrimaryAssetId).HasComment(
"The frame chosen to represent the burst. Restrict on delete: losing the cover "
+ "would leave a group that cannot be rendered.");
"The stored cover, the earliest member. SetNull on delete, so deleting the cover's row "
+ "(a storage config removal cascading to its assets) cannot fail on the burst; the "
+ "next BurstDetection run picks a new cover or deletes the burst.");

// Primary Asset reference (1:1 style lookup)
entity.HasOne(b => b.PrimaryAsset)
.WithMany()
.HasForeignKey(b => b.PrimaryAssetId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasQueryFilter(b => !b.PrimaryAsset.IsSoftDeleted);
.OnDelete(DeleteBehavior.SetNull);
entity.HasQueryFilter(b => b.PrimaryAssetId == null || !b.PrimaryAsset!.IsSoftDeleted);
});

modelBuilder.Entity<BurstCheck>(entity =>
{
entity.ToTable(t => t.HasComment(
"One row per photo burst detection has checked; no row means unchecked. Owned by the "
+ "BurstDetection work item, which reads the original once when it checks a photo. "
+ "A file edited in place loses its row, so it is checked again."
));
entity.Property(c => c.IsCandidate).HasComment(
"The photo has an EXIF capture time, a camera make and model, and a thumbnail; only "
+ "candidates are grouped. False also when the original could not be read.");
entity.Property(c => c.CameraBurstId).HasComment(
"Apple's BurstUUID from the original's maker note: photos of one storage config "
+ "sharing it form one burst. Null when the camera wrote none.");
entity.HasKey(c => c.AssetId);

entity.HasOne(c => c.Asset)
.WithOne()
.HasForeignKey<BurstCheck>(c => c.AssetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(c => c.CameraBurstId);
entity.HasQueryFilter(c => !c.Asset.IsSoftDeleted);
});

modelBuilder.Entity<MediaAsset>(entity =>
Expand Down
108 changes: 108 additions & 0 deletions src/Anichron.Core/Data/Repository/BurstRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using Anichron.Core.Domain;
using Microsoft.EntityFrameworkCore;

namespace Anichron.Core.Data.Repository;

/// <summary>Bursts and the burst-checked mark, written only by the Worker's burst detection.</summary>
public interface IBurstRepository
{
/// <summary>
/// The config's bursts, and bursts with no members left, each with every member, soft-deleted
/// ones included.
/// </summary>
/// <param name="storageConfigId">The config whose bursts are loaded.</param>
/// <param name="ct">Cancels the query.</param>
Task<IReadOnlyList<Burst>> GetInConfigWithMembersAsync(Guid storageConfigId, CancellationToken ct);

/// <summary>The bursts with every member, oldest first (then by id).</summary>
/// <param name="burstIds">The bursts to load.</param>
/// <param name="ct">Cancels the query.</param>
Task<IReadOnlyList<Burst>> GetWithMembersAsync(IReadOnlyCollection<Guid> burstIds, CancellationToken ct);

Task<int> CountUncheckedPhotosAsync(Guid storageConfigId, CancellationToken ct);

/// <summary>
/// The config's oldest active photos without a burst-checked mark, with their metadata and
/// proxy rows.
/// </summary>
/// <param name="storageConfigId">The config whose photos are loaded.</param>
/// <param name="count">At most this many.</param>
/// <param name="ct">Cancels the query.</param>
Task<IReadOnlyList<MediaAsset>> GetUncheckedPhotosAsync(Guid storageConfigId, int count, CancellationToken ct);

/// <summary>The config's checked candidates the camera labelled with this burst id.</summary>
/// <param name="storageConfigId">The config searched.</param>
/// <param name="cameraBurstId">Apple's BurstUUID.</param>
/// <param name="ct">Cancels the query.</param>
Task<IReadOnlyList<MediaAsset>> GetCandidatesLabelledAsync(Guid storageConfigId, string cameraBurstId, CancellationToken ct);

void Add(Burst burst);
void Remove(Burst burst);
void AddCheck(BurstCheck check);

/// <summary>Clears the asset's burst-checked mark, if it has one, at the next save.</summary>
/// <param name="assetId">The asset whose mark is cleared.</param>
/// <param name="ct">Cancels the lookup.</param>
Task RemoveCheckAsync(Guid assetId, CancellationToken ct);

/// <summary>Stops tracking everything loaded so far, so a long run does not hold it all.</summary>
void ForgetLoaded();
}

public sealed class EfBurstRepository(AnichronDbContext db) : IBurstRepository
{
public async Task<IReadOnlyList<Burst>> GetInConfigWithMembersAsync(Guid storageConfigId, CancellationToken ct)
=> await WithMembers()
.Where(b => b.Assets.All(a => a.StorageConfigId == storageConfigId))
.ToListAsync(ct);

public async Task<IReadOnlyList<Burst>> GetWithMembersAsync(IReadOnlyCollection<Guid> burstIds, CancellationToken ct)
=> await WithMembers()
.Where(b => burstIds.Contains(b.Id))
.OrderBy(b => b.CreatedAt).ThenBy(b => b.Id)
.ToListAsync(ct);

public Task<int> CountUncheckedPhotosAsync(Guid storageConfigId, CancellationToken ct)
=> UncheckedPhotos(storageConfigId).CountAsync(ct);

public async Task<IReadOnlyList<MediaAsset>> GetUncheckedPhotosAsync(Guid storageConfigId, int count, CancellationToken ct)
=> await UncheckedPhotos(storageConfigId)
.Include(a => a.Metadata)
.Include(a => a.ProxyFiles)
.OrderBy(a => a.DateCaptured).ThenBy(a => a.Id)
.Take(count)
.ToListAsync(ct);

public async Task<IReadOnlyList<MediaAsset>> GetCandidatesLabelledAsync(
Guid storageConfigId, string cameraBurstId, CancellationToken ct)
=> await db.BurstChecks
.Where(c => c.IsCandidate && c.CameraBurstId == cameraBurstId && c.Asset.StorageConfigId == storageConfigId)
.Select(c => c.Asset)
.ToListAsync(ct);

public void Add(Burst burst)
=> db.Bursts.Add(burst);

public void Remove(Burst burst)
=> db.Bursts.Remove(burst);

public void AddCheck(BurstCheck check)
=> db.BurstChecks.Add(check);

public async Task RemoveCheckAsync(Guid assetId, CancellationToken ct)
{
if (await db.BurstChecks.FindAsync([assetId], ct) is { } check)
db.BurstChecks.Remove(check);
}

public void ForgetLoaded()
=> db.ChangeTracker.Clear();

private IQueryable<Burst> WithMembers()
=> db.Bursts.IgnoreQueryFilters().Include(b => b.Assets);

private IQueryable<MediaAsset> UncheckedPhotos(Guid storageConfigId)
=> db.MediaAssets.Where(a => a.StorageConfigId == storageConfigId
&& (a.MediaType == MediaType.Image || a.MediaType == MediaType.LivePhoto)
&& !db.BurstChecks.Any(c => c.AssetId == a.Id));
}
9 changes: 5 additions & 4 deletions src/Anichron.Core/Domain/Burst.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
namespace Anichron.Core.Domain;
namespace Anichron.Core.Domain;

public class Burst
{
public Guid Id { get; set; }

/// <summary>
/// Points to the specific asset that should represent this burst in the UI.
/// The stored cover: the earliest member. Null once the cover row is deleted, until burst
/// detection repairs the burst.
/// </summary>
public Guid PrimaryAssetId { get; set; }
public Guid? PrimaryAssetId { get; set; }

public Instant CreatedAt { get; set; }

// Navigation Properties
public virtual MediaAsset PrimaryAsset { get; set; } = null!;
public virtual MediaAsset? PrimaryAsset { get; set; }
public virtual ICollection<MediaAsset> Assets { get; set; } = [];
}
Loading
Loading