diff --git a/go.mod b/go.mod index 2c7393f7..dd08857a 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.51.0 - golang.org/x/sync v0.21.0 + golang.org/x/sync v0.22.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -27,5 +27,6 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.39.0 // indirect + // x/text >= v0.41.0 for GO-2026-6629 (panic in x/text/secure/precis, reached via pgxpool). + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 55daf7a7..0a22b568 100644 --- a/go.sum +++ b/go.sum @@ -95,12 +95,12 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/db/migrations/0071_reopen_false_reacquisition_completions.down.sql b/internal/db/migrations/0071_reopen_false_reacquisition_completions.down.sql new file mode 100644 index 00000000..03bb6852 --- /dev/null +++ b/internal/db/migrations/0071_reopen_false_reacquisition_completions.down.sql @@ -0,0 +1,3 @@ +-- The reopened requests were never complete; the reconciler completes them +-- again once their albums come back. Nothing to undo. +SELECT 1; diff --git a/internal/db/migrations/0071_reopen_false_reacquisition_completions.up.sql b/internal/db/migrations/0071_reopen_false_reacquisition_completions.up.sql new file mode 100644 index 00000000..dd240a0b --- /dev/null +++ b/internal/db/migrations/0071_reopen_false_reacquisition_completions.up.sql @@ -0,0 +1,30 @@ +-- #5263: the reconciler completed album and track requests as soon as the album +-- had any track on disk. Re-acquisition targets albums with only SOME tracks +-- missing, so those requests completed the moment Lidarr accepted the add, +-- with nothing downloaded. +-- +-- Reopen every completed album/track request whose matched album does not meet +-- the corrected test (see albumForRequest in internal/lidarrrequests): a track +-- that arrived after the request, or no track that was already missing at the +-- request still missing. The reconciler then judges them again; the Lidarr add +-- stays confirmed, so nothing is re-sent. Requests that genuinely completed +-- meet the test and are left alone. +UPDATE lidarr_requests lr + SET status = 'approved', + completed_at = NULL, + matched_album_id = NULL, + matched_track_id = NULL, + updated_at = now() + WHERE lr.status = 'completed' + AND lr.kind IN ('album', 'track') + AND lr.matched_album_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM tracks t + WHERE t.album_id = lr.matched_album_id + AND t.missing_since IS NULL + AND t.added_at > lr.requested_at) + AND EXISTS ( + SELECT 1 FROM tracks t + WHERE t.album_id = lr.matched_album_id + AND t.missing_since IS NOT NULL + AND t.missing_since < lr.requested_at); diff --git a/internal/lidarrrequests/reconciler.go b/internal/lidarrrequests/reconciler.go index 589f5264..122bdb4d 100644 --- a/internal/lidarrrequests/reconciler.go +++ b/internal/lidarrrequests/reconciler.go @@ -258,16 +258,32 @@ func (r *Reconciler) repointAtReleaseGroup(ctx context.Context, q *dbq.Queries, return row, true } -// albumForRequest finds the library album a request's album id names, by -// release id or release group, and only once it has a track back on disk. A -// re-acquisition request names an album whose row never went away; matching -// the row alone would complete the request before Lidarr delivered anything. +// albumForRequest finds the library album a request's album id ($1) names, by +// release id or release group, once the request ($2 = requested_at) has been +// answered: the album has a track on disk AND either +// +// - a track arrived after the request (a new album, or Lidarr fetching +// another release of the group into its own row), or +// - no track that was already missing when the request was made is still +// missing (the lost files came back in place or were adopted at a new path). +// +// "Has a track on disk" alone is not enough (#5263): re-acquisition targets +// albums with ANY track missing, so the tracks that never left satisfied it +// the moment Lidarr accepted the add. added_at is the arrival clock because +// nothing rewrites it; updated_at moves on every tag re-read. // An exact release match is preferred over another release of the group. const albumForRequest = ` SELECT a.id FROM albums a WHERE (a.mbid = $1 OR a.release_group_mbid = $1) AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL) + AND ( + EXISTS (SELECT 1 FROM tracks t + WHERE t.album_id = a.id AND t.missing_since IS NULL AND t.added_at > $2) + OR NOT EXISTS (SELECT 1 FROM tracks t + WHERE t.album_id = a.id AND t.missing_since IS NOT NULL + AND t.missing_since < $2) + ) ORDER BY (a.mbid = $1) DESC, a.id LIMIT 1` @@ -301,7 +317,7 @@ func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq return nil } var albumID pgtype.UUID - err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid).Scan(&albumID) + err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid, row.RequestedAt).Scan(&albumID) if err != nil { if isNoRows(err) { return nil @@ -327,7 +343,7 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq } // Track-kind requests match via their parent album's MBID, not track.mbid. var albumID pgtype.UUID - err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid).Scan(&albumID) + err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid, row.RequestedAt).Scan(&albumID) if err != nil { if isNoRows(err) { return nil @@ -335,10 +351,10 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq return err } - // Load any track from that album to set matched_track_id. + // Load a track from that album to set matched_track_id, newest arrival first. var trackID pgtype.UUID err = r.pool.QueryRow(ctx, - "SELECT id FROM tracks WHERE album_id = $1 AND missing_since IS NULL ORDER BY id LIMIT 1", + "SELECT id FROM tracks WHERE album_id = $1 AND missing_since IS NULL ORDER BY added_at DESC, id LIMIT 1", albumID, ).Scan(&trackID) if err != nil { diff --git a/internal/lidarrrequests/reconciler_release_group_test.go b/internal/lidarrrequests/reconciler_release_group_test.go index f8cd3a22..696e2a4d 100644 --- a/internal/lidarrrequests/reconciler_release_group_test.go +++ b/internal/lidarrrequests/reconciler_release_group_test.go @@ -3,12 +3,17 @@ package lidarrrequests import ( "context" "encoding/json" + "io/fs" "net/http" "net/http/httptest" "strings" "sync" "testing" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" @@ -201,3 +206,168 @@ func TestReconciler_RepointsAReleaseIdRequestAtItsReleaseGroup(t *testing.T) { t.Errorf("MusicBrainz asked again on the next tick (%d -> %d)", before, asked) } } + +// markMissing stamps a track missing since well before any request a test makes. +func markMissing(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID) { + t.Helper() + if _, err := pool.Exec(context.Background(), + "UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1", trackID); err != nil { + t.Fatal(err) + } +} + +func requestStatus(t *testing.T, q *dbq.Queries, id pgtype.UUID) dbq.LidarrRequest { + t.Helper() + got, err := q.GetLidarrRequestByID(context.Background(), id) + if err != nil { + t.Fatal(err) + } + return got +} + +// #5263: re-acquisition targets albums with SOME tracks missing. The tracks +// that never left must not complete the request; it completes once the lost +// track is back. +func TestReconciler_PartlyMissingAlbumCompletesWhenTheLostTrackReturns(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + + const artistMBID, release = "rg-artist-partial", "rg-release-partial" + artist := seedArtist(t, q, "Partial Artist", artistMBID) + album := seedAlbum(t, q, artist.ID, "Partial Album", release) + _ = seedTrack(t, q, album.ID, artist.ID, "Kept", "/music/rg-partial/01.flac") + lost := seedTrack(t, q, album.ID, artist.ID, "Lost", "/music/rg-partial/02.flac") + markMissing(t, pool, lost.ID) + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Partial Artist", + LidarrAlbumMBID: release, AlbumTitle: "Partial Album", + }) + rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + if got := requestStatus(t, q, req.ID); got.Status != dbq.LidarrRequestStatusApproved { + t.Fatalf("status = %v while a track is still missing, want approved", got.Status) + } + + if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = NULL WHERE id = $1", lost.ID); err != nil { + t.Fatal(err) + } + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("second tick: %v", err) + } + got := requestStatus(t, q, req.ID) + if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != album.ID { + t.Errorf("status %v matched %v after the track returned, want completed on %v", + got.Status, got.MatchedAlbumID, album.ID) + } +} + +// Lidarr may fetch a different release of the group, which the scanner files +// as its own album row. A track arriving there after the request completes it, +// even though the old row's lost tracks stay missing. +func TestReconciler_PartlyMissingAlbumCompletesOnAnotherReleaseArriving(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + enableLidarrForPool(t, pool) + user := seedUser(t, pool) + + const artistMBID, oldRelease, newRelease, group = "rg-artist-edition", "rg-release-old", "rg-release-new", "rg-group-edition" + artist := seedArtist(t, q, "Edition Artist", artistMBID) + old := seedAlbum(t, q, artist.ID, "Edition Album", oldRelease) + if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ + ID: old.ID, ReleaseGroupMbid: nilableStr(group), + }); err != nil { + t.Fatal(err) + } + _ = seedTrack(t, q, old.ID, artist.ID, "Kept", "/music/rg-edition-old/01.flac") + lost := seedTrack(t, q, old.ID, artist.ID, "Lost", "/music/rg-edition-old/02.flac") + markMissing(t, pool, lost.ID) + + req := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Edition Artist", + LidarrAlbumMBID: group, AlbumTitle: "Edition Album", + }) + rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil) + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("tickOnce: %v", err) + } + if got := requestStatus(t, q, req.ID); got.Status != dbq.LidarrRequestStatusApproved { + t.Fatalf("status = %v before anything arrived, want approved", got.Status) + } + + arrived := seedAlbum(t, q, artist.ID, "Edition Album (Deluxe)", newRelease) + if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ + ID: arrived.ID, ReleaseGroupMbid: nilableStr(group), + }); err != nil { + t.Fatal(err) + } + _ = seedTrack(t, q, arrived.ID, artist.ID, "Lost", "/music/rg-edition-new/02.flac") + if err := rec.tickOnce(ctx); err != nil { + t.Fatalf("second tick: %v", err) + } + got := requestStatus(t, q, req.ID) + if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != arrived.ID { + t.Errorf("status %v matched %v, want completed on the arrived release %v", + got.Status, got.MatchedAlbumID, arrived.ID) + } +} + +// Migration 0071 reopens requests the old check completed against a partly +// missing album, and leaves a genuine completion alone. +func TestMigration0071_ReopensFalseCompletions(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + ctx := context.Background() + user := seedUser(t, pool) + + artist := seedArtist(t, q, "Reopen Artist", "rg-artist-reopen") + partial := seedAlbum(t, q, artist.ID, "Partial", "rg-release-reopen-partial") + _ = seedTrack(t, q, partial.ID, artist.ID, "Kept", "/music/rg-reopen-partial/01.flac") + lost := seedTrack(t, q, partial.ID, artist.ID, "Lost", "/music/rg-reopen-partial/02.flac") + markMissing(t, pool, lost.ID) + falseReq := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "album", LidarrArtistMBID: "rg-artist-reopen", ArtistName: "Reopen Artist", + LidarrAlbumMBID: "rg-release-reopen-partial", AlbumTitle: "Partial", + }) + + genuineReq := seedApprovedRequestDirect(t, q, user, CreateParams{ + Kind: "album", LidarrArtistMBID: "rg-artist-reopen", ArtistName: "Reopen Artist", + LidarrAlbumMBID: "rg-release-reopen-new", AlbumTitle: "New", + }) + arrived := seedAlbum(t, q, artist.ID, "New", "rg-release-reopen-new") + _ = seedTrack(t, q, arrived.ID, artist.ID, "Fresh", "/music/rg-reopen-new/01.flac") + + for _, c := range []struct { + id pgtype.UUID + album pgtype.UUID + }{{falseReq.ID, partial.ID}, {genuineReq.ID, arrived.ID}} { + if _, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{ + ID: c.id, MatchedAlbumID: c.album, + }); err != nil { + t.Fatal(err) + } + } + + up, err := fs.ReadFile(db.MigrationsFS(), "0071_reopen_false_reacquisition_completions.up.sql") + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, string(up)); err != nil { + t.Fatalf("migration: %v", err) + } + + got := requestStatus(t, q, falseReq.ID) + if got.Status != dbq.LidarrRequestStatusApproved || got.CompletedAt.Valid || got.MatchedAlbumID.Valid { + t.Errorf("false completion: status %v completed_at %v matched %v, want approved and cleared", + got.Status, got.CompletedAt, got.MatchedAlbumID) + } + if got := requestStatus(t, q, genuineReq.ID); got.Status != dbq.LidarrRequestStatusCompleted { + t.Errorf("genuine completion reopened: status %v", got.Status) + } +} diff --git a/tools/gen-brand-assets.py b/tools/gen-brand-assets.py index 6f5b72c6..8a5c3c3b 100644 --- a/tools/gen-brand-assets.py +++ b/tools/gen-brand-assets.py @@ -120,103 +120,6 @@ def wrap(d, width=78, indent=" " * 6): return ("\n" + indent).join(out) -# --------------------------------------------------------------------------- -# The reduced mark, for small sizes. -# -# NOT a simplification of the traced art — that was tried and does not work. -# The source composition is dominated by a long diagonal plume, so every -# mechanical reduction of it (hole-filling, morphological smoothing, dropping -# components) collapses at 16px into a diagonal smear that reads as no object -# at all. What reads at that size is a strong horizontal brim under a leaning -# crown, which has to be DRAWN rather than derived. -# -# So this is hand-authored geometry, tuned against the real thing: the crown -# peaks left of centre with a long right flank, the brim rides up at the right -# tip, and a band slit keeps crown and brim from fusing into one lump. It is a -# family member of the full mark, not a copy of it. -# --------------------------------------------------------------------------- - -RN = 640 # design-space square for the reduced mark - - -def _bez(pts, n=60): - """Sample a chain of cubic beziers given as [P0, C1,C2,P1, C1,C2,P2, ...].""" - out = [pts[0]] - for i in range(1, len(pts), 3): - p0, (c1, c2, p1) = out[-1], pts[i:i + 3] - for t in np.linspace(0, 1, n)[1:]: - u = 1 - t - out.append((u**3 * p0[0] + 3*u*u*t * c1[0] + 3*u*t*t * c2[0] + t**3 * p1[0], - u**3 * p0[1] + 3*u*u*t * c1[1] + 3*u*t*t * c2[1] + t**3 * p1[1])) - return out - - -def _reduced(plume): - """The reduced hat. plume=False gives crown+brim only; the plume is taken - as the difference between the two so it can be filled separately.""" - im = Image.new("L", (RN, RN), 0) - d = ImageDraw.Draw(im) - by = 336 - # Brim: the single strongest horizontal in the mark, and the shape that - # says "hat" at 16px, so it stays thick and unbroken end to end. - rt = by - 74 - d.polygon(_bez([(38, by + 10), - (150, by - 58), (430, by - 72), (590, rt), - (614, rt + 10), (610, rt + 40), (586, rt + 54), - (450, by + 56), (150, by + 58), (38, by + 30), - (22, by + 26), (22, by + 16), (38, by + 10)]), fill=255) - # Crown: peak pushed left of centre with a long right flank — the lean is - # what keeps this recognisably the same hat as the traced one. - px, top = 260, by - 256 - d.polygon(_bez([(190, by + 16), - (182, by - 96), (px - 92, top + 66), (px - 40, top + 10), - (px - 6, top - 14), (px + 44, top + 10), (px + 62, top + 56), - (px + 108, top + 150), (422, by - 120), (440, by - 34), - (448, by - 4), (424, by + 18), (190, by + 16)]), fill=255) - if plume: - # A pointed leaf, deliberately short: a long thin plume is exactly what - # turns the whole mark into a diagonal bar at small sizes. - d.polygon(_bez([(404, by - 158), - (466, by - 240), (546, by - 296), (586, by - 310), - (580, by - 270), (532, by - 196), (458, by - 132)]), fill=255) - a = np.asarray(im) > 128 - # Band slit: across the crown base only. Never across the brim — breaking - # the brim costs more legibility than the band gap buys. - g = Image.new("L", (RN, RN), 0) - ImageDraw.Draw(g).polygon(_bez([(192, by - 30), - (256, by - 50), (376, by - 54), (438, by - 58), - (446, by - 32), (300, by - 26), (192, by - 6)]), fill=255) - return a & ~(np.asarray(g) > 128) - - -def trace_reduced(tmp): - """Trace the reduced hat and plume as two fills, on one shared viewBox.""" - full, hat_only = _reduced(True), _reduced(False) - plume_only = full & ~hat_only - ys, xs = np.nonzero(full) - pad = 8 - box = (xs.min() - pad, ys.min() - pad, xs.max() + pad, ys.max() + pad) - - def one(mask, name): - # Ink BRIGHT here, matching what trace() feeds potrace: the point() - # below is what inverts to PBM's ink-is-black. Passing an already-dark - # mask double-inverts and silently traces the background instead. - img = Image.fromarray((mask * 255).astype(np.uint8)) - img = img.resize((RN * SUPERSAMPLE, RN * SUPERSAMPLE), Image.BICUBIC) - img = img.filter(ImageFilter.GaussianBlur(BLUR)) - img.point(lambda p: 0 if p > 128 else 255).convert("1", dither=Image.NONE).save(tmp / f"{name}.pbm") - subprocess.run(["potrace", *POTRACE, str(tmp / f"{name}.pbm"), "-o", str(tmp / f"{name}.svg")], check=True) - s = (tmp / f"{name}.svg").read_text() - return (" ".join(re.findall(r'\n' f'{head}{style}' @@ -277,27 +180,19 @@ def main(): " Literal colours, for any consumer that cannot inline the SVG and\n" " therefore cannot supply a currentColor. -->\n")) - # The reduced mark, for anywhere the full art cannot resolve. - rhat_d, rplume_d, rtr, rvb = trace_reduced(tmp) - - (ROOT / "web/static/brand/mark-small.svg").write_text(svg_doc( - rvb, rtr, PARCHMENT, ACCENT, rhat_d, rplume_d, - head=" \n")) - - # Favicon uses the REDUCED mark: a browser tab renders it at 16px, and the - # full art is unreadable there. This is the one consumer whose size is not - # ours to choose, so it takes the form drawn for that size. + # The tab icon is the full mark too (#5267). A hand-drawn reduced hat used + # to stand in at tab size because the art loses detail at 16px, but a + # redrawing reads as a different logo. The full mark keeps the logo's own + # shape, and a tab on a high-resolution screen draws it from 32px, where it + # holds. Do not swap a redrawn form back in here. (ROOT / "web/static/brand/favicon.svg").write_text(svg_doc( - rvb, rtr, "currentColor", ACCENT, rhat_d, rplume_d, + vb, tr, "currentColor", ACCENT, hat_d, notes_d, head=" \n", + " tab strip, obsidian on a dark one. The notes hold the accent in\n" + " both (3.04:1 on obsidian, 5.43:1 on the light ground). -->\n", style=" - - - + + + diff --git a/web/static/brand/mark-small.svg b/web/static/brand/mark-small.svg deleted file mode 100644 index c5a4c380..00000000 --- a/web/static/brand/mark-small.svg +++ /dev/null @@ -1,211 +0,0 @@ - - - - - - - diff --git a/web/static/favicon.png b/web/static/favicon.png index d606be8c..e605c635 100644 Binary files a/web/static/favicon.png and b/web/static/favicon.png differ