Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126

Merged
bvandeusen merged 23 commits from dev into main 2026-08-17 16:28:14 -04:00
5 changed files with 174 additions and 0 deletions
Showing only changes of commit 7ba673ed83 - Show all commits
+23
View File
@@ -102,3 +102,26 @@ func (q *Queries) InsertLibraryChange(ctx context.Context, arg InsertLibraryChan
_, err := q.db.Exec(ctx, insertLibraryChange, arg.EntityType, arg.EntityID, arg.Op)
return err
}
const insertLibraryChanges = `-- name: InsertLibraryChanges :exec
INSERT INTO library_changes (entity_type, entity_id, op)
SELECT $1::text,
unnest($2::text[]),
$3::text
`
type InsertLibraryChangesParams struct {
EntityType string
EntityIds []string
Op string
}
// Batch form, for a mutation that touches many rows at once (#2704: the scan's
// reconcile pass can mark up to a quarter of a library missing in one go).
// Every caller before this one changed a single entity, so per-row inserts
// were the right shape; a loop here would be thousands of round-trips inside
// a scan that is already the slow path.
func (q *Queries) InsertLibraryChanges(ctx context.Context, arg InsertLibraryChangesParams) error {
_, err := q.db.Exec(ctx, insertLibraryChanges, arg.EntityType, arg.EntityIds, arg.Op)
return err
}
+11
View File
@@ -2,6 +2,17 @@
INSERT INTO library_changes (entity_type, entity_id, op)
VALUES ($1, $2, $3);
-- name: InsertLibraryChanges :exec
-- Batch form, for a mutation that touches many rows at once (#2704: the scan's
-- reconcile pass can mark up to a quarter of a library missing in one go).
-- Every caller before this one changed a single entity, so per-row inserts
-- were the right shape; a loop here would be thousands of round-trips inside
-- a scan that is already the slow path.
INSERT INTO library_changes (entity_type, entity_id, op)
SELECT sqlc.arg(entity_type)::text,
unnest(sqlc.arg(entity_ids)::text[]),
sqlc.arg(op)::text;
-- name: GetLibraryChangesSince :many
SELECT id, entity_type, entity_id, op, changed_at
FROM library_changes
+43
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// trackReconciler is the slice of dbq.Queries reconcileMissing needs. Narrowed
@@ -18,6 +19,7 @@ type trackReconciler interface {
ListTrackPathsForReconcile(ctx context.Context) ([]dbq.ListTrackPathsForReconcileRow, error)
MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
InsertLibraryChanges(ctx context.Context, arg dbq.InsertLibraryChangesParams) error
}
// Reconcile marks tracks whose files have disappeared (#2523).
@@ -91,6 +93,9 @@ func (s *Scanner) reconcileMissing(
// otherwise a library that tripped the cap once could never recover its
// marks even after the mount came back.
if len(toClear) > 0 {
if err := logTrackChanges(ctx, q, toClear); err != nil {
return fmt.Errorf("log restored changes: %w", err)
}
n, err := q.ClearTracksMissing(ctx, toClear)
if err != nil {
return fmt.Errorf("clear missing marks: %w", err)
@@ -110,6 +115,9 @@ func (s *Scanner) reconcileMissing(
)
}
if err := logTrackChanges(ctx, q, toMark); err != nil {
return fmt.Errorf("log missing changes: %w", err)
}
n, err := q.MarkTracksMissing(ctx, toMark)
if err != nil {
return fmt.Errorf("mark tracks missing: %w", err)
@@ -123,6 +131,41 @@ func (s *Scanner) reconcileMissing(
return nil
}
// logTrackChanges tells the delta sync that these tracks changed, so clients
// pick up the missing mark (#2704).
//
// Without this the mark was invisible to every client: MarkTracksMissing and
// ClearTracksMissing are plain UPDATEs, and /api/library/sync is a change-log
// feed — a row that never produces a change is never re-sent, so a client
// would keep its stale copy until an unrelated edit touched the track or the
// cursor fell out of the retention window.
//
// Logged BEFORE the mutation, deliberately, which is the opposite of the
// scanner's log-after-success pattern. The two failure modes are not
// symmetric: log-then-fail-to-mark makes clients re-fetch a track that has
// not changed, which costs one wasted read. Mark-then-fail-to-log leaves the
// mark in place with no change row, and because both statements are
// idempotent (`missing_since IS NULL` / `IS NOT NULL` guards) the next scan
// will not retry the pair — so the client never learns, permanently. A
// spurious re-read is the cheaper mistake.
func logTrackChanges(ctx context.Context, q trackReconciler, ids []pgtype.UUID) error {
if len(ids) == 0 {
return nil
}
strIDs := make([]string, 0, len(ids))
for _, id := range ids {
strIDs = append(strIDs, syncpkg.FormatUUID(id))
}
// OpUpsert, not OpDelete: the track still exists and keeps its history —
// only its playability changed. A delete would tell clients to drop the
// row, which is the behaviour #2704 deliberately rejected.
return q.InsertLibraryChanges(ctx, dbq.InsertLibraryChangesParams{
EntityType: string(syncpkg.EntityTrack),
EntityIds: strIDs,
Op: string(syncpkg.OpUpsert),
})
}
// verifyRootsPresent is the first and most important guard. If a configured root
// doesn't resolve to a readable directory, the walk beneath it found nothing and
// every row under it would look deleted. An unmounted media volume is the
+74
View File
@@ -26,6 +26,8 @@ type fakeReconciler struct {
listErr error
markErr error
clearErr error
// Change rows the reconcile pass asked the delta sync to emit (#2704).
loggedChanges []dbq.InsertLibraryChangesParams
}
func (f *fakeReconciler) ListTrackPathsForReconcile(context.Context) ([]dbq.ListTrackPathsForReconcileRow, error) {
@@ -40,6 +42,13 @@ func (f *fakeReconciler) MarkTracksMissing(_ context.Context, ids []pgtype.UUID)
return int64(len(ids)), nil
}
func (f *fakeReconciler) InsertLibraryChanges(
_ context.Context, arg dbq.InsertLibraryChangesParams,
) error {
f.loggedChanges = append(f.loggedChanges, arg)
return nil
}
func (f *fakeReconciler) ClearTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
if f.clearErr != nil {
return 0, f.clearErr
@@ -324,3 +333,68 @@ func TestVerifyRootsPresent_NoRootsConfigured(t *testing.T) {
t.Fatal("expected an error with no scan roots configured")
}
}
// Without a change row the mark is invisible to every client:
// /api/library/sync is a change-log feed, so a plain UPDATE never reaches
// anyone. This was the gap that made #2704's wire field inert — the flag
// existed and nothing ever told a client to re-read the track.
func TestReconcileMissing_MarkingEmitsSyncChanges(t *testing.T) {
s := testScanner(t, populatedRoot(t))
// 10 rows, 2 absent — under the cap, so this exercises marking.
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
seen := map[string]struct{}{}
for i := 0; i < 10; i++ {
path := fmt.Sprintf("/music/track-%02d.mp3", i)
rows = append(rows, row(byte(i), path, false))
if i >= 2 {
seen[path] = struct{}{}
}
}
q := &fakeReconciler{rows: rows}
var stats Stats
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
t.Fatalf("reconcile: %v", err)
}
if len(q.loggedChanges) != 1 {
t.Fatalf("want one batch of change rows, got %d", len(q.loggedChanges))
}
got := q.loggedChanges[0]
if got.EntityType != "track" {
t.Errorf("entity_type = %q, want track", got.EntityType)
}
// Upsert, not delete: the track still exists and keeps its history. A
// delete would tell clients to drop the row, which is the behaviour
// #2704 deliberately rejected.
if got.Op != "upsert" {
t.Errorf("op = %q, want upsert — the row survives, only its state changed", got.Op)
}
if len(got.EntityIds) != 2 {
t.Errorf("want both missing tracks logged, got %d ids", len(got.EntityIds))
}
}
// A file coming back must reach clients too, or a restored track stays
// greyed out on every device until something unrelated touches it.
func TestReconcileMissing_RestoringEmitsSyncChanges(t *testing.T) {
s := testScanner(t, populatedRoot(t))
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
row(1, "/music/back.mp3", true),
}}
var stats Stats
if err := s.reconcileMissing(
context.Background(), q, map[string]struct{}{"/music/back.mp3": {}}, &stats,
); err != nil {
t.Fatalf("reconcile: %v", err)
}
if len(q.loggedChanges) != 1 {
t.Fatalf("a restored file must produce a change row, got %d batches", len(q.loggedChanges))
}
if q.loggedChanges[0].Op != "upsert" {
t.Errorf("op = %q, want upsert", q.loggedChanges[0].Op)
}
}
+23
View File
@@ -36,6 +36,29 @@ func LogChange(ctx context.Context, dbtx dbq.DBTX, entityType EntityType, entity
return nil
}
// LogChanges is LogChange for a set of entities of one type sharing one op.
//
// Exists because the scan's reconcile pass can mark a quarter of a library
// missing in a single sweep (#2704), and a per-row loop there would be
// thousands of round-trips inside an operation that is already slow. Every
// other caller mutates one entity and should keep using LogChange.
//
// A no-op on an empty set, so callers don't have to guard.
func LogChanges(ctx context.Context, dbtx dbq.DBTX, entityType EntityType, entityIDs []string, op Op) error {
if len(entityIDs) == 0 {
return nil
}
q := dbq.New(dbtx)
if err := q.InsertLibraryChanges(ctx, dbq.InsertLibraryChangesParams{
EntityType: string(entityType),
EntityIds: entityIDs,
Op: string(op),
}); err != nil {
return fmt.Errorf("sync.LogChanges(%s, %d ids, %s): %w", entityType, len(entityIDs), op, err)
}
return nil
}
// FormatUUID renders a pgtype.UUID as the canonical 8-4-4-4-12 hex form.
// Returns "" if the UUID is not valid.
func FormatUUID(u pgtype.UUID) string {