From 35188503b520b426a50e5520844b02730d372ff2 Mon Sep 17 00:00:00 2001
From: Matt Morris
Date: Tue, 8 Sep 2026 15:34:18 -0500
Subject: [PATCH 1/6] feat: Retry failed play submissions
- Persist submission state and original records for reliable retries
- Add authenticated failed-plays page with individual and batch retry actions
---
README.md | 25 +++
cmd/failed_plays.go | 153 ++++++++++++++++++
cmd/failed_plays_test.go | 102 ++++++++++++
cmd/handlers.go | 10 +-
cmd/routes.go | 2 +
db/db.go | 36 ++++-
db/submissions.go | 115 ++++++++++++++
db/submissions_test.go | 110 +++++++++++++
models/track.go | 3 +-
pages/static/main.css | 35 ++++-
pages/templates/components/navBar.gohtml | 1 +
pages/templates/failedPlays.gohtml | 55 +++++++
service/applemusic/applemusic.go | 4 +-
service/atproto/submission.go | 144 ++++++++++++++---
service/atproto/submission_test.go | 190 +++++++++++++++++++++++
service/lastfm/lastfm.go | 7 +-
service/spotify/spotify.go | 34 +---
17 files changed, 950 insertions(+), 76 deletions(-)
create mode 100644 cmd/failed_plays.go
create mode 100644 cmd/failed_plays_test.go
create mode 100644 db/submissions.go
create mode 100644 db/submissions_test.go
create mode 100644 pages/templates/failedPlays.gohtml
create mode 100644 service/atproto/submission_test.go
diff --git a/README.md b/README.md
index 58376aa..aa2ef6a 100644
--- a/README.md
+++ b/README.md
@@ -166,3 +166,28 @@ Sensitive environment variables (Spotify, ATProto, Last.fm credentials) should b
```
See [module.nix](./module.nix) for additional configuration options.
+
+## Failed play submissions
+
+Open **Failed plays** in Piper's navigation to inspect saved plays that have not
+been confirmed published. Each entry includes its source, play time, submission
+status, attempt count, last attempt time, and last error. Retry an individual play
+or the current page of up to 20 plays. Batch retries stop on the first failure.
+For `invalid_grant`, log out and sign in again before retrying.
+
+Eligible plays and their pending submissions are saved in one transaction.
+Publishing success is tracked separately from `hasStamped`, which still means
+that a play met its source's eligibility rules. A retry uses the current OAuth
+session and the original saved record key and payload, including the play time.
+An interrupted submission can be retried after its two-minute claim expires.
+Retries are user-initiated; there is no automatic retry scheduler.
+
+Server logs include `play_submission` events with user ID, play ID, record key,
+attempt number, outcome, and a safe error summary. OAuth response bodies and
+credentials are not saved in submission errors. HTTP status and actionable
+errors such as `invalid_grant` are retained.
+
+Historical tracks saved before submission tracking was introduced have unknown
+publishing outcomes. They are not automatically queued or labelled as failures.
+Recovering an older gap requires comparing the saved tracks with PDS records
+before backfilling, to avoid duplicating successful submissions.
diff --git a/cmd/failed_plays.go b/cmd/failed_plays.go
new file mode 100644
index 0000000..999de5f
--- /dev/null
+++ b/cmd/failed_plays.go
@@ -0,0 +1,153 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/teal-fm/piper/db"
+ atprotoauth "github.com/teal-fm/piper/oauth/atproto"
+ "github.com/teal-fm/piper/pages"
+ atprotoservice "github.com/teal-fm/piper/service/atproto"
+ "github.com/teal-fm/piper/session"
+)
+
+type failedPlaysParams struct {
+ NavBar pages.NavBar
+ Plays []db.PlaySubmission
+ CSRF string
+ Notice string
+ Previous int
+ Next int
+ HasPrevious bool
+ HasNext bool
+}
+
+func failedPlays(database *db.DB, pg *pages.Pages, auth *atprotoauth.AuthService) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ userID, ok := session.GetUserID(r.Context())
+ if !ok {
+ http.Error(w, "Sign in to view failed plays.", http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ if r.Method != http.MethodGet && r.Method != http.MethodPost {
+ w.Header().Set("Allow", "GET, POST")
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if r.Method == http.MethodPost {
+ r.Body = http.MaxBytesReader(w, r.Body, 8192)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Invalid retry request", http.StatusBadRequest)
+ return
+ }
+ cookie, err := r.Cookie("piper_retry_csrf")
+ if err != nil || len(cookie.Value) != 64 || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(r.PostForm.Get("csrf"))) != 1 {
+ http.Error(w, "Refresh this page before retrying.", http.StatusForbidden)
+ return
+ }
+ if origin := r.Header.Get("Origin"); origin != "" {
+ parsed, err := url.Parse(origin)
+ if err != nil || parsed.Host != r.Host || (parsed.Scheme != "http" && parsed.Scheme != "https") {
+ http.Error(w, "Invalid request origin", http.StatusForbidden)
+ return
+ }
+ }
+ ids := r.PostForm["play_id"]
+ if len(ids) == 0 || len(ids) > 20 {
+ http.Error(w, "Choose between 1 and 20 plays.", http.StatusBadRequest)
+ return
+ }
+ // Validate the complete request before publishing anything.
+ parsedIDs := make([]int64, 0, len(ids))
+ seen := map[int64]bool{}
+ for _, raw := range ids {
+ id, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || id <= 0 {
+ http.Error(w, "Invalid play ID", http.StatusBadRequest)
+ return
+ }
+ if _, err := database.GetTrackForUser(userID, id); err != nil {
+ http.Error(w, "Play not found", http.StatusNotFound)
+ return
+ }
+ if !seen[id] {
+ parsedIDs = append(parsedIDs, id)
+ seen[id] = true
+ }
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
+ defer cancel()
+ published, failed, skipped := 0, 0, 0
+ for _, id := range parsedIDs {
+ err := atprotoservice.PublishStoredPlay(ctx, database, userID, id, auth)
+ if errors.Is(err, db.ErrSubmissionUnavailable) {
+ skipped++
+ continue
+ }
+ if err != nil {
+ failed++
+ break
+ }
+ published++
+ }
+ notice := fmt.Sprintf("Published %d. Failed %d. Unavailable %d. Not attempted %d.", published, failed, skipped, len(parsedIDs)-published-failed-skipped)
+ http.Redirect(w, r, "/failed-plays?notice="+url.QueryEscape(notice), http.StatusSeeOther)
+ return
+ }
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ if offset < 0 || offset > 1000000 {
+ offset = 0
+ }
+ plays, err := database.ListUnpublishedPlays(userID, 21, offset)
+ if err != nil {
+ log.Printf("failed_plays user_id=%d error=%q", userID, err)
+ http.Error(w, "Could not load failed plays. Try again.", http.StatusInternalServerError)
+ return
+ }
+ if offset > 0 && len(plays) == 0 {
+ http.Redirect(w, r, "/failed-plays", http.StatusSeeOther)
+ return
+ }
+ user, err := database.GetUserByID(userID)
+ if err != nil {
+ http.Error(w, "Could not load account", http.StatusInternalServerError)
+ return
+ }
+ token := ""
+ if cookie, err := r.Cookie("piper_retry_csrf"); err == nil && len(cookie.Value) == 64 {
+ token = cookie.Value
+ }
+ if token == "" {
+ var b [32]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ http.Error(w, "Could not prepare retry form", http.StatusInternalServerError)
+ return
+ }
+ token = hex.EncodeToString(b[:])
+ http.SetCookie(w, &http.Cookie{Name: "piper_retry_csrf", Value: token, Path: "/failed-plays", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil})
+ }
+ params := failedPlaysParams{NavBar: pages.NewNavBar(user, true).WithBreadcrumb("Failed plays"), Plays: plays, CSRF: token, Notice: r.URL.Query().Get("notice"), HasPrevious: offset > 0, Previous: max(0, offset-20), HasNext: len(plays) > 20, Next: offset + 20}
+ if len(params.Plays) > 20 {
+ params.Plays = params.Plays[:20]
+ }
+ var body bytes.Buffer
+ if err := pg.Execute("failedPlays", &body, params); err != nil {
+ log.Printf("failed plays template: %v", err)
+ http.Error(w, "Could not display failed plays", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(body.Bytes())
+ }
+}
diff --git a/cmd/failed_plays_test.go b/cmd/failed_plays_test.go
new file mode 100644
index 0000000..5bd919f
--- /dev/null
+++ b/cmd/failed_plays_test.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/teal-fm/piper/db"
+ "github.com/teal-fm/piper/models"
+ "github.com/teal-fm/piper/pages"
+ "github.com/teal-fm/piper/session"
+)
+
+func TestFailedPlaysPageAndRetryAuthorization(t *testing.T) {
+ database, err := db.New(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ if err := database.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ uid, err := database.CreateUser(&models.User{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ other, err := database.CreateUser(&models.User{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ save := func(userID int64, name string) int64 {
+ t.Helper()
+ id, err := database.SaveTrack(userID, db.SourceAppleMusic, &models.Track{Name: name, HasStamped: true, Timestamp: time.Now().UTC()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return id
+ }
+ own := save(uid, "")
+ foreign := save(other, "Another user's private play")
+ handler := failedPlays(database, pages.NewPages(), nil)
+ ctx := session.WithUserID(context.Background(), uid)
+ get := httptest.NewRequest(http.MethodGet, "/failed-plays", nil).WithContext(ctx)
+ response := httptest.NewRecorder()
+ handler(response, get)
+ if response.Code != http.StatusOK {
+ t.Fatalf("GET: %d %s", response.Code, response.Body.String())
+ }
+ body := response.Body.String()
+ if strings.Contains(body, "Another user's private play") || strings.Contains(body, "") || !strings.Contains(body, "<script>") {
+ t.Fatalf("unsafe rendered page: %s", body)
+ }
+ cookies := response.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatal("missing CSRF cookie")
+ }
+ csrf := cookies[0]
+ for _, tc := range []struct {
+ name string
+ ids []int64
+ token string
+ origin string
+ code int
+ }{
+ {"missing CSRF", []int64{own}, "", "", http.StatusForbidden},
+ {"foreign play", []int64{foreign}, csrf.Value, "", http.StatusNotFound},
+ {"mixed ownership", []int64{own, foreign}, csrf.Value, "", http.StatusNotFound},
+ {"cross origin", []int64{own}, csrf.Value, "https://evil.example", http.StatusForbidden},
+ {"retry own", []int64{own}, csrf.Value, "", http.StatusSeeOther},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ form := url.Values{"csrf": {tc.token}}
+ for _, id := range tc.ids {
+ form.Add("play_id", strconv.FormatInt(id, 10))
+ }
+ request := httptest.NewRequest(http.MethodPost, "/failed-plays", strings.NewReader(form.Encode())).WithContext(ctx)
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ request.Header.Set("Origin", tc.origin)
+ request.AddCookie(csrf)
+ result := httptest.NewRecorder()
+ handler(result, request)
+ if result.Code != tc.code {
+ t.Fatalf("POST = %d: %s", result.Code, result.Body.String())
+ }
+ })
+ }
+ plays, err := database.ListUnpublishedPlays(uid, 20, 0)
+ if err != nil || len(plays) != 1 || plays[0].Attempts != 1 || !strings.Contains(plays[0].LastError, "Sign in") {
+ t.Fatalf("retry failure not visible: %+v %v", plays, err)
+ }
+ result := httptest.NewRecorder()
+ handler(result, httptest.NewRequest(http.MethodGet, "/failed-plays", nil))
+ if result.Code != http.StatusUnauthorized {
+ t.Fatal("anonymous access allowed")
+ }
+}
diff --git a/cmd/handlers.go b/cmd/handlers.go
index c904aa3..90d4018 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -671,7 +671,7 @@ func apiSubmitListensHandler(database *db.DB, atprotoService *atprotoauth.AuthSe
// lookups are rate limited to 1/s, so doing them before responding
// can outlast the proxy timeout and trap clients in a retry loop
if len(savedListens) > 0 {
- go hydrateAndSubmitListens(database, atprotoService, mbService, user, userID, savedListens)
+ go hydrateAndSubmitListens(database, atprotoService, mbService, userID, savedListens)
}
// Prepare response
@@ -704,7 +704,7 @@ type savedListen struct {
// hydrateAndSubmitListens hydrates saved listens with MusicBrainz data and
// submits them to the PDS. It runs detached from the request that saved them,
// on its own context, since both steps can far outlast the client connection.
-func hydrateAndSubmitListens(database *db.DB, atprotoService *atprotoauth.AuthService, mbService *musicbrainz.Service, user *models.User, userID int64, listens []savedListen) {
+func hydrateAndSubmitListens(database *db.DB, atprotoService *atprotoauth.AuthService, mbService *musicbrainz.Service, userID int64, listens []savedListen) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
@@ -723,10 +723,8 @@ func hydrateAndSubmitListens(database *db.DB, atprotoService *atprotoauth.AuthSe
}
}
- if user.ATProtoDID != nil && atprotoService != nil {
- if err := atprotoservice.SubmitPlayToPDS(ctx, *user.ATProtoDID, *user.MostRecentAtProtoSessionID, &track, atprotoService); err != nil {
- log.Printf("apiSubmitListensHandler: Error submitting play to PDS for user %d: %v", userID, err)
- }
+ if err := atprotoservice.PublishStoredPlay(ctx, database, userID, saved.trackID, atprotoService); err != nil {
+ log.Printf("apiSubmitListensHandler: Error submitting play to PDS for user %d: %v", userID, err)
}
}
}
diff --git a/cmd/routes.go b/cmd/routes.go
index e33b601..6b1b3f1 100644
--- a/cmd/routes.go
+++ b/cmd/routes.go
@@ -11,6 +11,8 @@ import (
func (app *application) routes() http.Handler {
mux := http.NewServeMux()
+ mux.HandleFunc("/failed-plays", session.WithAuth(failedPlays(app.database, app.pages, app.atprotoService), app.sessionManager))
+
//Handles static file routes
mux.Handle("/static/{file_name}", app.pages.Static())
diff --git a/db/db.go b/db/db.go
index 6753da9..f9e346d 100644
--- a/db/db.go
+++ b/db/db.go
@@ -1,7 +1,9 @@
package db
import (
+ "crypto/rand"
"database/sql"
+ "encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -11,6 +13,7 @@ import (
"strings"
"time"
+ "github.com/bluesky-social/indigo/atproto/syntax"
_ "github.com/mattn/go-sqlite3"
"github.com/teal-fm/piper/models"
)
@@ -35,6 +38,9 @@ func New(dbPath string) (*DB, error) {
return nil, err
}
+ // SQLite has one writer; one connection also keeps :memory: databases consistent.
+ db.SetMaxOpenConns(1)
+
// Test the connection
if err = db.Ping(); err != nil {
return nil, err
@@ -192,6 +198,9 @@ func (db *DB) Initialize() error {
return err
}
+ if err := db.initializeSubmissions(); err != nil {
+ return err
+ }
return db.backfillTrackSources()
}
@@ -492,15 +501,36 @@ func (db *DB) SaveTrack(userID int64, source TrackSource, track *models.Track) (
}
var trackID int64
-
- err := db.QueryRow(`
+ tx, err := db.Begin()
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+ err = tx.QueryRow(`
INSERT INTO tracks (user_id, name, recording_mbid, artist, album, release_mbid, url, timestamp, duration_ms, progress_ms, service_base_url, isrc, has_stamped, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id`,
userID, track.Name, track.RecordingMBID, artistString, track.Album, track.ReleaseMBID, track.URL, track.Timestamp,
track.DurationMs, track.ProgressMs, track.ServiceBaseUrl, track.ISRC, track.HasStamped, source).Scan(&trackID)
- return trackID, err
+ if err != nil {
+ return 0, err
+ }
+ if track.HasStamped {
+ var clockBytes [2]byte
+ if _, err := rand.Read(clockBytes[:]); err != nil {
+ return 0, err
+ }
+ rkey := syntax.NewTIDNow(uint(binary.BigEndian.Uint16(clockBytes[:]) & 1023)).String()
+ if _, err := tx.Exec(`INSERT INTO play_submissions(track_id,rkey) VALUES (?,?)`, trackID, rkey); err != nil {
+ return 0, err
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, err
+ }
+ track.PlayID = trackID
+ return trackID, nil
}
// HasTrackListen reports whether a listen with the same name and timestamp
diff --git a/db/submissions.go b/db/submissions.go
new file mode 100644
index 0000000..540aeae
--- /dev/null
+++ b/db/submissions.go
@@ -0,0 +1,115 @@
+package db
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/teal-fm/piper/models"
+)
+
+// A missing submission row means the historical publishing outcome is unknown.
+// HasStamped describes eligibility, not delivery to the PDS.
+func (db *DB) initializeSubmissions() error {
+ _, err := db.Exec(`CREATE TABLE IF NOT EXISTS play_submissions (
+ track_id INTEGER PRIMARY KEY REFERENCES tracks(id),
+ rkey TEXT NOT NULL UNIQUE,
+ status TEXT NOT NULL DEFAULT 'pending',
+ attempts INTEGER NOT NULL DEFAULT 0,
+ last_attempt_at TIMESTAMP,
+ last_error TEXT NOT NULL DEFAULT '',
+ record_json TEXT,
+ published_at TIMESTAMP
+ )`)
+ return err
+}
+
+type PlaySubmission struct {
+ TrackID int64
+ Name string
+ Artists []models.Artist
+ Source string
+ PlayedAt time.Time
+ Status string
+ Attempts int
+ LastAttemptAt sql.NullTime
+ LastError string
+ RKey string
+ RecordJSON sql.NullString
+}
+
+func (db *DB) ListUnpublishedPlays(userID int64, limit, offset int) ([]PlaySubmission, error) {
+ rows, err := db.Query(`SELECT t.id, t.name, t.artist, t.source, t.timestamp, s.status, s.attempts,
+ s.last_attempt_at, s.last_error FROM play_submissions s JOIN tracks t ON t.id=s.track_id
+ WHERE t.user_id=? AND s.status != 'published' ORDER BY t.timestamp, t.id LIMIT ? OFFSET ?`, userID, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var plays []PlaySubmission
+ for rows.Next() {
+ var p PlaySubmission
+ var artists string
+ if err := rows.Scan(&p.TrackID, &p.Name, &artists, &p.Source, &p.PlayedAt, &p.Status, &p.Attempts, &p.LastAttemptAt, &p.LastError); err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal([]byte(artists), &p.Artists); err != nil && artists != "" {
+ p.Artists = []models.Artist{{Name: artists}}
+ }
+ plays = append(plays, p)
+ }
+ return plays, rows.Err()
+}
+
+func (db *DB) GetTrackForUser(userID, trackID int64) (*models.Track, error) {
+ return scanTrack(db.QueryRow(`SELECT id, name, recording_mbid, artist, album, release_mbid, url,
+ timestamp, duration_ms, progress_ms, service_base_url, isrc, has_stamped FROM tracks WHERE id=? AND user_id=?`, trackID, userID))
+}
+
+var ErrSubmissionUnavailable = errors.New("play is already published, currently being submitted, or unavailable")
+
+// ClaimSubmission uses an expiring claim so crashes do not strand plays. The
+// attempt number fences late results from an older worker after lease expiry.
+func (db *DB) ClaimSubmission(userID, trackID int64) (*PlaySubmission, error) {
+ var p PlaySubmission
+ err := db.QueryRow(`UPDATE play_submissions SET status='submitting', attempts=attempts+1, last_attempt_at=?
+ WHERE track_id=? AND track_id IN (SELECT id FROM tracks WHERE user_id=?)
+ AND (status IN ('pending','failed') OR (status='submitting' AND last_attempt_at < ?))
+ RETURNING track_id,rkey,attempts,record_json`, time.Now().UTC(), trackID, userID, time.Now().UTC().Add(-2*time.Minute)).Scan(&p.TrackID, &p.RKey, &p.Attempts, &p.RecordJSON)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrSubmissionUnavailable
+ }
+ return &p, err
+}
+
+func (db *DB) SaveSubmissionRecord(p *PlaySubmission, record string) error {
+ res, err := db.Exec(`UPDATE play_submissions SET record_json=? WHERE track_id=? AND attempts=? AND status='submitting'`, record, p.TrackID, p.Attempts)
+ return submissionUpdated(res, err)
+}
+
+func (db *DB) FinishSubmission(p *PlaySubmission, message string) error {
+ status := "published"
+ var publishedAt any = time.Now().UTC()
+ if message != "" {
+ status = "failed"
+ publishedAt = nil
+ }
+ res, err := db.Exec(`UPDATE play_submissions SET status=?, last_error=?, published_at=? WHERE track_id=? AND attempts=? AND status='submitting'`, status, message, publishedAt, p.TrackID, p.Attempts)
+ return submissionUpdated(res, err)
+}
+
+func submissionUpdated(res sql.Result, err error) error {
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n != 1 {
+ return fmt.Errorf("submission claim expired")
+ }
+ return nil
+}
diff --git a/db/submissions_test.go b/db/submissions_test.go
new file mode 100644
index 0000000..0f853b8
--- /dev/null
+++ b/db/submissions_test.go
@@ -0,0 +1,110 @@
+package db
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/teal-fm/piper/models"
+)
+
+func TestSubmissionEligibilityAndLegacyMigration(t *testing.T) {
+ database := newTestDB(t)
+ uid := createTestUser(t, database)
+ // Historical has_stamped=true must not be mistaken for a known failure.
+ if _, err := database.Exec(`INSERT INTO tracks(user_id,name,artist,album,url,has_stamped) VALUES (?,'legacy','[]','','',1)`, uid); err != nil {
+ t.Fatal(err)
+ }
+ for _, eligible := range []bool{false, true} {
+ track := &models.Track{Name: "new", Timestamp: time.Now().UTC(), HasStamped: eligible}
+ id, err := database.SaveTrack(uid, SourceAppleMusic, track)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if track.PlayID != id {
+ t.Fatal("saved track missing its play ID")
+ }
+ }
+ if err := database.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ plays, err := database.ListUnpublishedPlays(uid, 20, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(plays) != 1 || plays[0].Status != "pending" {
+ t.Fatalf("unexpected submissions: %+v", plays)
+ }
+}
+
+func TestSubmissionClaimsOwnershipRetryAndExpiry(t *testing.T) {
+ database := newTestDB(t)
+ uid := createTestUser(t, database)
+ other := createTestUser(t, database)
+ id, err := database.SaveTrack(uid, SourceAppleMusic, &models.Track{Name: "play", HasStamped: true, Timestamp: time.Now().UTC()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := database.ClaimSubmission(other, id); !errors.Is(err, ErrSubmissionUnavailable) {
+ t.Fatalf("other user claimed play: %v", err)
+ }
+ first, err := database.ClaimSubmission(uid, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := database.ClaimSubmission(uid, id); !errors.Is(err, ErrSubmissionUnavailable) {
+ t.Fatalf("duplicate claim: %v", err)
+ }
+ if _, err := database.Exec(`UPDATE play_submissions SET last_attempt_at=? WHERE track_id=?`, time.Now().UTC().Add(-3*time.Minute), id); err != nil {
+ t.Fatal(err)
+ }
+ second, err := database.ClaimSubmission(uid, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.RKey != first.RKey {
+ t.Fatal("retry changed record key")
+ }
+ if err := database.FinishSubmission(first, ""); err == nil {
+ t.Fatal("stale worker changed current claim")
+ }
+ if err := database.FinishSubmission(second, "HTTP 400: invalid_grant"); err != nil {
+ t.Fatal(err)
+ }
+ retry, err := database.ClaimSubmission(uid, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if retry.Attempts != 3 {
+ t.Fatalf("attempts = %d", retry.Attempts)
+ }
+ if err := database.FinishSubmission(retry, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := database.ClaimSubmission(uid, id); !errors.Is(err, ErrSubmissionUnavailable) {
+ t.Fatalf("published play reclaimed: %v", err)
+ }
+ plays, err := database.ListUnpublishedPlays(uid, 20, 0)
+ if err != nil || len(plays) != 0 {
+ t.Fatalf("published play still listed: %+v %v", plays, err)
+ }
+}
+
+func TestSaveTrackRollsBackWhenSubmissionCannotBeSaved(t *testing.T) {
+ database := newTestDB(t)
+ uid := createTestUser(t, database)
+ if _, err := database.Exec(`CREATE TRIGGER reject_submission BEFORE INSERT ON play_submissions BEGIN SELECT RAISE(ABORT, 'storage failure'); END`); err != nil {
+ t.Fatal(err)
+ }
+ track := &models.Track{Name: "Must not be stranded", HasStamped: true, Timestamp: time.Now().UTC()}
+ if _, err := database.SaveTrack(uid, SourceAppleMusic, track); err == nil {
+ t.Fatal("expected storage failure")
+ }
+ tracks, err := database.GetRecentTracks(uid, 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(tracks) != 0 || track.PlayID != 0 {
+ t.Fatal("track survived without its pending submission")
+ }
+}
diff --git a/models/track.go b/models/track.go
index 4f09ff8..c1715a6 100644
--- a/models/track.go
+++ b/models/track.go
@@ -18,7 +18,8 @@ type Track struct {
ProgressMs int64 `json:"progressMs"`
ServiceBaseUrl string `json:"serviceBaseUrl"`
ISRC string `json:"isrc"`
- HasStamped bool `json:"hasStamped"`
+ // HasStamped indicates a play met its source eligibility rules, not PDS success.
+ HasStamped bool `json:"hasStamped"`
}
type Artist struct {
diff --git a/pages/static/main.css b/pages/static/main.css
index c62ba0a..039cec7 100644
--- a/pages/static/main.css
+++ b/pages/static/main.css
@@ -221,9 +221,6 @@
.static {
position: static;
}
- .sticky {
- position: sticky;
- }
.inset-0 {
inset: calc(var(--spacing) * 0);
}
@@ -251,12 +248,18 @@
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
+ .mt-2 {
+ margin-top: calc(var(--spacing) * 2);
+ }
.mt-3 {
margin-top: calc(var(--spacing) * 3);
}
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
+ .mt-6 {
+ margin-top: calc(var(--spacing) * 6);
+ }
.mt-12 {
margin-top: calc(var(--spacing) * 12);
}
@@ -343,6 +346,9 @@
.shrink-0 {
flex-shrink: 0;
}
+ .grow {
+ flex-grow: 1;
+ }
.border-collapse {
border-collapse: collapse;
}
@@ -367,6 +373,12 @@
.items-center {
align-items: center;
}
+ .items-start {
+ align-items: flex-start;
+ }
+ .justify-between {
+ justify-content: space-between;
+ }
.justify-center {
justify-content: center;
}
@@ -389,6 +401,13 @@
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
}
}
+ .space-y-5 {
+ :where(& > :not(:last-child)) {
+ --tw-space-y-reverse: 0;
+ margin-block-start: calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));
+ margin-block-end: calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)));
+ }
+ }
.gap-x-4 {
column-gap: calc(var(--spacing) * 4);
}
@@ -416,6 +435,10 @@
border-style: var(--tw-border-style);
border-width: 1px;
}
+ .border-t {
+ border-top-style: var(--tw-border-style);
+ border-top-width: 1px;
+ }
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
@@ -530,6 +553,9 @@
.py-8 {
padding-block: calc(var(--spacing) * 8);
}
+ .pt-4 {
+ padding-top: calc(var(--spacing) * 4);
+ }
.pr-2 {
padding-right: calc(var(--spacing) * 2);
}
@@ -581,6 +607,9 @@
--tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight);
}
+ .break-words {
+ overflow-wrap: break-word;
+ }
.break-all {
word-break: break-all;
}
diff --git a/pages/templates/components/navBar.gohtml b/pages/templates/components/navBar.gohtml
index c4d92dd..37f3fee 100644
--- a/pages/templates/components/navBar.gohtml
+++ b/pages/templates/components/navBar.gohtml
@@ -20,6 +20,7 @@
Now playing
History
{{ end }}
+ Failed plays
API keys
Failed plays
+Plays saved by Piper that have not been confirmed published. Retry them here after fixing the connection.
+{{ if .Notice }}{{ .Notice }}
{{ end }}
+{{ if .Plays }}
+If an error says invalid_grant, log out and sign in again, then return here. Retries keep the original play time.
+
+
+{{ range .Plays }}
+-
+
+
+
{{ .Name }}
+
{{ range $i, $artist := .Artists }}{{ if $i }}, {{ end }}{{ $artist.Name }}{{ end }}
+
{{ .Source }} · {{ formatTime .PlayedAt.UTC }} UTC · Play #{{ .TrackID }}
+
+
+
+ {{ .Status }} · {{ .Attempts }} attempt{{ if ne .Attempts 1 }}s{{ end }}{{ if .LastAttemptAt.Valid }} · Last attempt {{ formatTime .LastAttemptAt.Time.UTC }} UTC{{ end }}
+ {{ if .LastError }}{{ .LastError }}
+ {{ else }}{{ if eq .Status "submitting" }}Submission is in progress. If it was interrupted, retry after two minutes.{{ else }}Saved and waiting for submission.{{ end }}
{{ end }}
+
+{{ end }}
+
+
+{{ else }}
+
+
No failed or pending plays
+
New publishing failures will appear here with an error and a retry option.
+
+{{ end }}
+Older plays saved before submission tracking was added have an unknown publishing status and are not listed here. Recovering them requires checking which records are missing from your PDS.
+
+{{ end }}
diff --git a/service/applemusic/applemusic.go b/service/applemusic/applemusic.go
index 69a3fa5..2b0ab2d 100644
--- a/service/applemusic/applemusic.go
+++ b/service/applemusic/applemusic.go
@@ -610,8 +610,8 @@ func (s *Service) ProcessUser(ctx context.Context, user *models.User) error {
}
// Submit to PDS
- if user.ATProtoDID != nil && user.MostRecentAtProtoSessionID != nil && s.atprotoService != nil {
- if err := atprotoservice.SubmitPlayToPDS(ctx, *user.ATProtoDID, *user.MostRecentAtProtoSessionID, track, s.atprotoService); err != nil {
+ if track.HasStamped {
+ if err := atprotoservice.PublishStoredPlay(ctx, s.DB, user.ID, track.PlayID, s.atprotoService); err != nil {
s.logger.Printf("failed submit to PDS for user %d: %v", user.ID, err)
}
}
diff --git a/service/atproto/submission.go b/service/atproto/submission.go
index 8a119f0..3e335f8 100644
--- a/service/atproto/submission.go
+++ b/service/atproto/submission.go
@@ -2,51 +2,151 @@ package atproto
import (
"context"
+ "encoding/json"
+ "errors"
"fmt"
"log"
+ "reflect"
+ "regexp"
+ "strings"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
+ "github.com/bluesky-social/indigo/atproto/client"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/spf13/viper"
"github.com/teal-fm/piper/api/teal"
+ "github.com/teal-fm/piper/db"
"github.com/teal-fm/piper/models"
atprotoauth "github.com/teal-fm/piper/oauth/atproto"
)
-// SubmitPlayToPDS submits a track play to the ATProto PDS as a feed.play record
-func SubmitPlayToPDS(ctx context.Context, did string, mostRecentAtProtoSessionID string, track *models.Track, atprotoService *atprotoauth.AuthService) error {
- if did == "" {
- return fmt.Errorf("DID cannot be empty")
- }
+// PublishStoredPlay submits a saved, eligible play using the user's current session.
+// The durable claim is shared by ingestion and manual retries.
+func PublishStoredPlay(ctx context.Context, database *db.DB, userID, trackID int64, auth *atprotoauth.AuthService) error {
+ return publishStoredPlay(ctx, database, userID, trackID, func(ctx context.Context, user *models.User, key string, record *teal.FeedPlay) error {
+ if auth == nil || user.ATProtoDID == nil || user.MostRecentAtProtoSessionID == nil || *user.MostRecentAtProtoSessionID == "" {
+ return errors.New("Sign in to Piper again to reconnect publishing.")
+ }
+ client, err := auth.GetATProtoClient(*user.ATProtoDID, *user.MostRecentAtProtoSessionID, ctx)
+ if err != nil {
+ return fmt.Errorf("opening OAuth session: %s", submissionError(err))
+ }
+ if client == nil {
+ return errors.New("OAuth session returned no client. Sign in to Piper again.")
+ }
+ return createPlayRecord(ctx, client, *user.ATProtoDID, key, record)
+ })
+}
- // Get ATProto client
- client, err := atprotoService.GetATProtoClient(did, mostRecentAtProtoSessionID, ctx)
- if err != nil || client == nil {
- return fmt.Errorf("failed to get ATProto client: %w", err)
- }
+type playPublisher func(context.Context, *models.User, string, *teal.FeedPlay) error
- // Convert track to feed.play record
- playRecord, err := TrackToPlayRecord(track)
+func publishStoredPlay(ctx context.Context, database *db.DB, userID, trackID int64, publish playPublisher) error {
+ p, err := database.ClaimSubmission(userID, trackID)
if err != nil {
- return fmt.Errorf("failed to convert track to play record: %w", err)
+ return err
}
-
- // Create the record
- input := comatproto.RepoCreateRecord_Input{
- Collection: "fm.teal.feed.play",
- Repo: client.AccountDID.String(),
- Record: &lexutil.LexiconTypeDecoder{Val: playRecord},
+ ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
+ defer cancel()
+ err = func() error {
+ user, err := database.GetUserByID(userID)
+ if err != nil {
+ return errors.New("Could not load the publishing account.")
+ }
+ if user == nil {
+ return errors.New("Publishing account was not found.")
+ }
+ var record *teal.FeedPlay
+ if p.RecordJSON.Valid {
+ if err := json.Unmarshal([]byte(p.RecordJSON.String), &record); err != nil {
+ return errors.New("Could not read saved play record.")
+ }
+ } else {
+ track, err := database.GetTrackForUser(userID, trackID)
+ if err != nil {
+ return errors.New("Could not load saved play.")
+ }
+ record, err = TrackToPlayRecord(track)
+ if err != nil {
+ return err
+ }
+ encoded, err := json.Marshal(record)
+ if err != nil {
+ return errors.New("Could not encode saved play.")
+ }
+ if err := database.SaveSubmissionRecord(p, string(encoded)); err != nil {
+ return errors.New("Could not save publishing record.")
+ }
+ }
+ return publish(ctx, user, p.RKey, record)
+ }()
+ message := ""
+ if err != nil {
+ message = err.Error()
+ }
+ if saveErr := database.FinishSubmission(p, message); saveErr != nil {
+ log.Printf("play_submission user_id=%d play_id=%d attempt=%d outcome=persistence_failed error=%q", userID, trackID, p.Attempts, saveErr)
+ return fmt.Errorf("saving publishing outcome: %w", saveErr)
+ }
+ outcome := "published"
+ if err != nil {
+ outcome = "failed"
}
+ log.Printf("play_submission user_id=%d play_id=%d rkey=%s attempt=%d outcome=%s error=%q", userID, trackID, p.RKey, p.Attempts, outcome, message)
+ return err
+}
+// createRecord retains create-only OAuth permissions. If the write succeeded
+// but its response was lost, verify the existing record at the same key.
+func createPlayRecord(ctx context.Context, client lexutil.LexClient, did, key string, record *teal.FeedPlay) error {
+ input := comatproto.RepoCreateRecord_Input{Collection: "fm.teal.feed.play", Repo: did, Rkey: &key, Record: &lexutil.LexiconTypeDecoder{Val: record}}
if _, err := comatproto.RepoCreateRecord(ctx, client, &input); err != nil {
- return fmt.Errorf("failed to create play record for DID %s: %w", did, err)
+ existing, readErr := comatproto.RepoGetRecord(ctx, client, "", "fm.teal.feed.play", did, key)
+ if readErr == nil && existing != nil && existing.Value != nil {
+ // Compare decoded JSON so field order does not affect equality.
+ a, _ := json.Marshal(existing.Value)
+ b, _ := json.Marshal(record)
+ var av, bv any
+ if json.Unmarshal(a, &av) == nil && json.Unmarshal(b, &bv) == nil && reflect.DeepEqual(av, bv) {
+ return nil
+ }
+ }
+ return fmt.Errorf("publishing play: %s", submissionError(err))
}
-
- log.Printf("Successfully submitted play to PDS for DID %s: %s - %s", did, track.Artist[0].Name, track.Name)
return nil
}
+var httpStatusPattern = regexp.MustCompile(`HTTP[ )]*(\d{3})`)
+var errorNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,63}$`)
+
+// Never persist arbitrary response bodies, URLs, or token-bearing OAuth errors.
+// Keep the actionable code, HTTP status, and operation instead.
+func submissionError(err error) string {
+ if errors.Is(err, context.DeadlineExceeded) {
+ return "Request timed out. Retry this play."
+ }
+ if errors.Is(err, context.Canceled) {
+ return "Request was interrupted. Retry this play."
+ }
+ detail := "Request failed. Retry this play."
+ var apiErr *client.APIError
+ if errors.As(err, &apiErr) {
+ detail = fmt.Sprintf("HTTP %d", apiErr.StatusCode)
+ if errorNamePattern.MatchString(apiErr.Name) {
+ detail += ": " + apiErr.Name
+ }
+ } else if match := httpStatusPattern.FindStringSubmatch(err.Error()); len(match) > 1 {
+ detail = "HTTP " + match[1]
+ }
+ if strings.Contains(err.Error(), "invalid_grant") {
+ detail += ": invalid_grant. Sign in to Piper again, then retry."
+ }
+ if strings.Contains(err.Error(), "failed to refresh OAuth tokens") || strings.Contains(err.Error(), "token refresh failed") {
+ detail = "Failed to refresh OAuth tokens. " + detail
+ }
+ return detail
+}
+
// TrackToPlayRecord converts a models.Track to teal.FeedPlay
func TrackToPlayRecord(track *models.Track) (*teal.FeedPlay, error) {
if track.Name == "" {
diff --git a/service/atproto/submission_test.go b/service/atproto/submission_test.go
new file mode 100644
index 0000000..188c00b
--- /dev/null
+++ b/service/atproto/submission_test.go
@@ -0,0 +1,190 @@
+package atproto
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/client"
+ "github.com/bluesky-social/indigo/xrpc"
+ "github.com/teal-fm/piper/api/teal"
+ "github.com/teal-fm/piper/db"
+ "github.com/teal-fm/piper/models"
+)
+
+func TestFailedSubmissionSurvivesRestartAndRetriesOriginalRecord(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "piper.db")
+ database, err := db.New(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := database.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ uid, err := database.CreateUser(&models.User{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ playedAt := time.Date(2026, 9, 3, 18, 3, 0, 0, time.UTC)
+ track := &models.Track{Name: "Original title", Timestamp: playedAt, HasStamped: true}
+ id, err := database.SaveTrack(uid, db.SourceAppleMusic, track)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var originalKey string
+ err = publishStoredPlay(context.Background(), database, uid, id, func(ctx context.Context, u *models.User, key string, r *teal.FeedPlay) error {
+ originalKey = key
+ return errors.New("publishing play: HTTP 400: invalid_grant. Sign in to Piper again, then retry.")
+ })
+ if err == nil {
+ t.Fatal("expected publishing failure")
+ }
+ plays, err := database.ListUnpublishedPlays(uid, 20, 0)
+ if err != nil || len(plays) != 1 || plays[0].Attempts != 1 || !strings.Contains(plays[0].LastError, "invalid_grant") {
+ t.Fatalf("failure not saved: %+v %v", plays, err)
+ }
+ database.Close()
+ database, err = db.New(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ if err := database.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ // Later hydration cannot change the record sent by a retry.
+ if _, err := database.Exec(`UPDATE tracks SET name='Changed title' WHERE id=?`, id); err != nil {
+ t.Fatal(err)
+ }
+ err = publishStoredPlay(context.Background(), database, uid, id, func(ctx context.Context, u *models.User, key string, r *teal.FeedPlay) error {
+ if key != originalKey || r.TrackName != "Original title" || r.PlayedTime == nil || *r.PlayedTime != playedAt.Format(time.RFC3339) {
+ t.Fatalf("retry changed original record: %s %+v", key, r)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ plays, err = database.ListUnpublishedPlays(uid, 20, 0)
+ if err != nil || len(plays) != 0 {
+ t.Fatalf("successful retry still listed: %+v %v", plays, err)
+ }
+}
+
+func TestCreatePlayRecordVerifiesAmbiguousWrite(t *testing.T) {
+ for _, matches := range []bool{true, false} {
+ t.Run(fmt.Sprint(matches), func(t *testing.T) {
+ record := &teal.FeedPlay{LexiconTypeID: "fm.teal.feed.play", TrackName: "Track"}
+ key := "3jzfcijpj2z2a"
+ creates := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method == http.MethodPost {
+ var input struct {
+ RKey string `json:"rkey"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ t.Error(err)
+ }
+ if input.RKey != key {
+ t.Errorf("unstable key %q", input.RKey)
+ }
+ creates++
+ w.WriteHeader(http.StatusInternalServerError)
+ fmt.Fprint(w, `{"error":"InternalServerError"}`)
+ return
+ }
+ if r.URL.Query().Get("rkey") != key {
+ t.Errorf("wrong verification key")
+ }
+ existing := *record
+ if !matches {
+ existing.TrackName = "Different play"
+ }
+ json.NewEncoder(w).Encode(map[string]any{"uri": "at://did:plc:test/fm.teal.feed.play/" + key, "value": existing})
+ }))
+ defer server.Close()
+ err := createPlayRecord(context.Background(), &xrpc.Client{Host: server.URL, Client: server.Client()}, "did:plc:test", key, record)
+ if matches && err != nil {
+ t.Fatal(err)
+ }
+ if !matches && err == nil {
+ t.Fatal("different remote record counted as successful")
+ }
+ if creates != 1 {
+ t.Fatalf("unexpected create count %d", creates)
+ }
+ })
+ }
+}
+
+func TestSubmissionErrorsKeepActionableDetailsWithoutSecrets(t *testing.T) {
+ for _, tc := range []struct {
+ err error
+ want string
+ }{
+ {errors.New("failed to refresh OAuth tokens: token refresh failed (HTTP 400): invalid_grant secret-token"), "HTTP 400: invalid_grant"},
+ {&client.APIError{StatusCode: 429, Name: "RateLimitExceeded", Message: "secret-token"}, "HTTP 429: RateLimitExceeded"},
+ {errors.New("https://secret-token@host/?refresh_token=secret-token"), "Request failed"},
+ {context.DeadlineExceeded, "timed out"},
+ } {
+ got := submissionError(tc.err)
+ if !strings.Contains(got, tc.want) || strings.Contains(got, "secret-token") {
+ t.Errorf("unsafe or unhelpful error %q", got)
+ }
+ }
+}
+
+func TestOverlappingRetriesPublishOnce(t *testing.T) {
+ database, err := db.New(filepath.Join(t.TempDir(), "piper.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer database.Close()
+ if err := database.Initialize(); err != nil {
+ t.Fatal(err)
+ }
+ uid, err := database.CreateUser(&models.User{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := database.SaveTrack(uid, db.SourceAppleMusic, &models.Track{Name: "One play", HasStamped: true, Timestamp: time.Now().UTC()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ started := make(chan struct{})
+ release := make(chan struct{})
+ done := make(chan error, 1)
+ go func() {
+ done <- publishStoredPlay(context.Background(), database, uid, id, func(ctx context.Context, u *models.User, key string, r *teal.FeedPlay) error {
+ close(started)
+ <-release
+ return nil
+ })
+ }()
+ select {
+ case <-started:
+ case err := <-done:
+ t.Fatalf("first retry exited before publishing: %v", err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("first retry did not start")
+ }
+ err = publishStoredPlay(context.Background(), database, uid, id, func(ctx context.Context, u *models.User, key string, r *teal.FeedPlay) error {
+ t.Error("overlapping retry attempted a second publish")
+ return nil
+ })
+ close(release)
+ if !errors.Is(err, db.ErrSubmissionUnavailable) {
+ t.Errorf("overlapping retry = %v", err)
+ }
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/service/lastfm/lastfm.go b/service/lastfm/lastfm.go
index cf7058f..8f014eb 100644
--- a/service/lastfm/lastfm.go
+++ b/service/lastfm/lastfm.go
@@ -487,7 +487,7 @@ func (l *Service) processTracks(ctx context.Context, username string, tracks []T
return err
}
l.logger.Printf("Submitting track")
- err = l.SubmitTrackToPDS(*user.ATProtoDID, *user.MostRecentAtProtoSessionID, hydratedTrack, ctx)
+ err = atprotoservice.PublishStoredPlay(ctx, l.db, user.ID, hydratedTrack.PlayID, l.atprotoService)
if err != nil {
l.logger.Printf("error submitting track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err)
}
@@ -510,11 +510,6 @@ func (l *Service) processTracks(ctx context.Context, username string, tracks []T
return nil
}
-func (l *Service) SubmitTrackToPDS(did string, mostRecentAtProtoSessionID string, track *models.Track, ctx context.Context) error {
- // Use shared atproto service for submission
- return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, l.atprotoService)
-}
-
// convertLastFMTrackToModelsTrack converts a Last.fm Track to models.Track format
func (l *Service) convertLastFMTrackToModelsTrack(track Track) *models.Track {
// Create artist array
diff --git a/service/spotify/spotify.go b/service/spotify/spotify.go
index 7bda7ed..4c4e5e2 100644
--- a/service/spotify/spotify.go
+++ b/service/spotify/spotify.go
@@ -91,17 +91,6 @@ func NewSpotifyService(database *db.DB, atprotoAuthService *atprotoauth.AuthServ
}
}
-func (s *Service) SubmitTrackToPDS(did string, mostRecentAtProtoSessionID string, track *models.Track, ctx context.Context) error {
- //Had a empty feed.play get submitted not sure why. Tracking here
- if track.Name == "" {
- s.logger.Println("Track name is empty. Skipping submission. Please record the logs before and send to the teal.fm Discord")
- return nil
- }
-
- // Use shared atproto service for submission
- return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, s.atprotoAuthService)
-}
-
func (s *Service) SetAccessToken(token string, refreshToken string, userId int64) (int64, error) {
userID, err := s.identifyAndStoreUser(token, refreshToken, userId)
if err != nil {
@@ -828,29 +817,8 @@ func (s *Service) stampTrack(ctx context.Context, userID int64, track *models.Tr
return
}
- // Submit play record to ATProto PDS
-
- // Fetch the user
- dbUser, err := s.DB.GetUserByID(userID)
- if err != nil {
- s.logger.Printf("User %d: Error fetching user for PDS: %v", userID, err)
- return
- }
- if dbUser == nil {
- s.logger.Printf("User %d: User not found in DB. Skipping PDS submission.", userID)
- return
- }
- if dbUser.ATProtoDID == nil || *dbUser.ATProtoDID == "" {
- // No DID configured, skip PDS submission silently
- return
- }
-
- // Perform submission to PDS
- s.logger.Printf("User %d: Submitting track '%s' to PDS (DID: %s)", userID, trackToSubmit.Name, *dbUser.ATProtoDID)
- if err := s.SubmitTrackToPDS(*dbUser.ATProtoDID, *dbUser.MostRecentAtProtoSessionID, trackToSubmit, ctx); err != nil {
+ if err := atprotoservice.PublishStoredPlay(ctx, s.DB, userID, trackToSubmit.PlayID, s.atprotoAuthService); err != nil {
s.logger.Printf("User %d: Error submitting to PDS: %v", userID, err)
- } else {
- s.logger.Printf("User %d: Successfully submitted track '%s' to PDS", userID, trackToSubmit.Name)
}
}
--
knotmirror 0.0.1
From 963c720196faf1d1f6a12a87084f62307fd388dd Mon Sep 17 00:00:00 2001
From: Matt Morris
Date: Tue, 8 Sep 2026 15:45:28 -0500
Subject: [PATCH 2/6] fix: Show sign-in errors inline and widen page layout
---
cmd/handlers.go | 16 +++++---
models/constants.go | 2 +-
oauth/atproto/atproto.go | 24 ++++++++----
oauth/atproto/login_test.go | 31 ++++++++++++++++
oauth/oauth_manager.go | 4 ++
pages/login.go | 21 +++++++++++
pages/navbar_test.go | 8 ++--
pages/static/main.css | 47 +++++++++++++++++-------
pages/templates/components/navBar.gohtml | 8 ++--
pages/templates/home.gohtml | 5 +++
pages/templates/layouts/base.gohtml | 2 +-
pages/templates_test.go | 20 ++++++++++
12 files changed, 152 insertions(+), 36 deletions(-)
create mode 100644 oauth/atproto/login_test.go
create mode 100644 pages/login.go
diff --git a/cmd/handlers.go b/cmd/handlers.go
index 90d4018..ed15f4a 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -25,9 +25,11 @@ import (
)
type HomeParams struct {
- NavBar pages.NavBar
- BuildTime time.Time
- Agent string
+ LoginError string
+ LoginHandle string
+ NavBar pages.NavBar
+ BuildTime time.Time
+ Agent string
}
func home(database *db.DB, pg *pages.Pages, lastfmService *lastfm.Service, atprotoService *atprotoauth.AuthService, buildTime time.Time) http.HandlerFunc {
@@ -49,9 +51,11 @@ func home(database *db.DB, pg *pages.Pages, lastfmService *lastfm.Service, atpro
}
params := HomeParams{
- NavBar: pages.NewNavBar(user, isLoggedIn),
- BuildTime: buildTime,
- Agent: models.SubmissionAgent,
+ NavBar: pages.NewNavBar(user, isLoggedIn),
+ BuildTime: buildTime,
+ Agent: models.SubmissionAgent,
+ LoginError: pages.LoginErrorMessage(r.URL.Query().Get("login_error")),
+ LoginHandle: r.URL.Query().Get("handle"),
}
err := pg.Execute("home", w, params)
if err != nil {
diff --git a/models/constants.go b/models/constants.go
index 74f6fda..930a29e 100644
--- a/models/constants.go
+++ b/models/constants.go
@@ -1,3 +1,3 @@
package models
-const SubmissionAgent = "piper/v0.0.11"
+const SubmissionAgent = "piper/v0.0.12"
diff --git a/oauth/atproto/atproto.go b/oauth/atproto/atproto.go
index 4deb991..2cd49c7 100644
--- a/oauth/atproto/atproto.go
+++ b/oauth/atproto/atproto.go
@@ -93,7 +93,7 @@ func (a *AuthService) HandleLogin(w http.ResponseWriter, r *http.Request) {
handle := r.URL.Query().Get("handle")
if handle == "" {
a.logger.Printf("ATProto Login Error: handle is required")
- http.Error(w, "handle query parameter is required", http.StatusBadRequest)
+ redirectLoginError(w, r, "missing_handle", handle)
return
}
ctx := r.Context()
@@ -102,30 +102,34 @@ func (a *AuthService) HandleLogin(w http.ResponseWriter, r *http.Request) {
atid, err := syntax.ParseAtIdentifier(handle)
if err != nil {
- http.Error(w, fmt.Sprintf("Error parsing AT Identifier (%s): %v", handle, err), http.StatusInternalServerError)
+ a.logger.Printf("Error parsing AT Identifier %q: %v", handle, err)
+ redirectLoginError(w, r, "invalid_handle", handle)
return
}
ident, err := a.clientApp.Dir.Lookup(ctx, *atid)
if err != nil {
- http.Error(w, fmt.Sprintf("Error resolving DID for AT Identifier (%s): %v", handle, err), http.StatusInternalServerError)
+ a.logger.Printf("Error resolving AT Identifier %q: %v", handle, err)
+ redirectLoginError(w, r, "lookup_failed", handle)
return
}
accountDid := ident.DID.String()
if len(a.allowedDids) > 0 && !slices.Contains(a.allowedDids, accountDid) {
a.logger.Printf("ATProto Login Error: DID %s for handle %s is not in the allowed list", accountDid, handle)
- http.Error(w, "Unauthorized", http.StatusForbidden)
+ redirectLoginError(w, r, "not_allowed", handle)
return
}
redirectURL, err := a.clientApp.StartAuthFlow(ctx, accountDid)
if err != nil {
- http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
+ a.logger.Printf("Error initiating login: %v", err)
+ redirectLoginError(w, r, "login_failed", handle)
return
}
authUrl, err := url.Parse(redirectURL)
if err != nil {
- http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
+ a.logger.Printf("Error initiating login: %v", err)
+ redirectLoginError(w, r, "login_failed", handle)
return
}
@@ -133,6 +137,12 @@ func (a *AuthService) HandleLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, authUrl.String(), http.StatusFound)
}
+// Redirect with a fixed error code so OAuth details never enter the page URL.
+func redirectLoginError(w http.ResponseWriter, r *http.Request, code, handle string) {
+ query := url.Values{"login_error": {code}, "handle": {handle}}
+ http.Redirect(w, r, "/?"+query.Encode(), http.StatusSeeOther)
+}
+
func (a *AuthService) HandleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
@@ -209,7 +219,6 @@ func (a *AuthService) HandleCallback(w http.ResponseWriter, r *http.Request) (in
sessData, err := a.clientApp.ProcessCallback(ctx, r.URL.Query())
if err != nil {
errMsg := fmt.Errorf("processing OAuth callback: %w", err)
- http.Error(w, errMsg.Error(), http.StatusBadRequest)
return 0, errMsg
}
@@ -222,7 +231,6 @@ func (a *AuthService) HandleCallback(w http.ResponseWriter, r *http.Request) (in
user, err := a.DB.FindOrCreateUserByDID(sessData.AccountDID.String())
if err != nil {
a.logger.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", sessData.AccountDID.String(), err)
- http.Error(w, "Failed to process user information.", http.StatusInternalServerError)
return 0, fmt.Errorf("failed to find or create user")
}
diff --git a/oauth/atproto/login_test.go b/oauth/atproto/login_test.go
new file mode 100644
index 0000000..974ac79
--- /dev/null
+++ b/oauth/atproto/login_test.go
@@ -0,0 +1,31 @@
+package atproto
+
+import (
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+)
+
+func TestInvalidLoginReturnsToForm(t *testing.T) {
+ service := &AuthService{logger: log.New(io.Discard, "", 0)}
+ for _, tc := range []struct{ handle, code string }{{"asdf./asdf", "invalid_handle"}, {"", "missing_handle"}} {
+ t.Run(tc.code, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/login/atproto?"+url.Values{"handle": {tc.handle}}.Encode(), nil)
+ response := httptest.NewRecorder()
+ service.HandleLogin(response, req)
+ if response.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d", response.Code)
+ }
+ location, err := url.Parse(response.Header().Get("Location"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if location.Path != "/" || location.Query().Get("login_error") != tc.code || location.Query().Get("handle") != tc.handle {
+ t.Fatalf("unexpected redirect: %s", location)
+ }
+ })
+ }
+}
diff --git a/oauth/oauth_manager.go b/oauth/oauth_manager.go
index 9707972..c2a82b7 100644
--- a/oauth/oauth_manager.go
+++ b/oauth/oauth_manager.go
@@ -88,6 +88,10 @@ func (m *ServiceManager) HandleCallback(serviceName string) http.HandlerFunc {
if err != nil {
m.logger.Printf("Error handling callback for service '%s': %v", serviceName, err)
+ if serviceName == "atproto" {
+ http.Redirect(w, r, "/?login_error=callback_failed", http.StatusSeeOther)
+ return
+ }
http.Error(w, fmt.Sprintf("Error handling callback for service '%s'", serviceName), http.StatusInternalServerError)
return
}
diff --git a/pages/login.go b/pages/login.go
new file mode 100644
index 0000000..901a98e
--- /dev/null
+++ b/pages/login.go
@@ -0,0 +1,21 @@
+package pages
+
+// LoginErrorMessage accepts only known error codes, never arbitrary URL text.
+func LoginErrorMessage(code string) string {
+ switch code {
+ case "missing_handle":
+ return "Enter your ATProto handle to sign in."
+ case "invalid_handle":
+ return "Enter a valid ATProto handle, such as alice.bsky.social. Do not include a URL or slash."
+ case "lookup_failed":
+ return "We could not find that account. Check your handle and try again."
+ case "not_allowed":
+ return "This account is not allowed to sign in to this Piper instance."
+ case "login_failed":
+ return "Could not start sign-in with your provider. Please try again."
+ case "callback_failed":
+ return "Sign-in could not be completed. Please try again."
+ default:
+ return ""
+ }
+}
diff --git a/pages/navbar_test.go b/pages/navbar_test.go
index 6fda5cb..e5dcfd4 100644
--- a/pages/navbar_test.go
+++ b/pages/navbar_test.go
@@ -13,9 +13,11 @@ func ptr(s string) *string { return &s }
// homeParams mirrors cmd.HomeParams, which this package can't import.
type homeParams struct {
- NavBar NavBar
- BuildTime time.Time
- Agent string
+ LoginError string
+ LoginHandle string
+ NavBar NavBar
+ BuildTime time.Time
+ Agent string
}
func TestNewNavBar(t *testing.T) {
diff --git a/pages/static/main.css b/pages/static/main.css
index 039cec7..32d8a83 100644
--- a/pages/static/main.css
+++ b/pages/static/main.css
@@ -8,8 +8,11 @@
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco,
Consolas, "Liberation Mono", "Courier New", monospace;
+ --color-red-300: oklch(80.8% 0.114 19.571);
--color-red-400: oklch(70.4% 0.191 22.216);
+ --color-red-500: oklch(63.7% 0.237 25.331);
--color-red-600: oklch(57.7% 0.245 27.325);
+ --color-red-700: oklch(50.5% 0.213 27.518);
--color-neutral-100: oklch(97% 0 0);
--color-neutral-200: oklch(92.2% 0 0);
--color-neutral-300: oklch(87% 0 0);
@@ -23,6 +26,7 @@
--spacing: 0.25rem;
--container-sm: 24rem;
--container-3xl: 48rem;
+ --container-6xl: 72rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem;
@@ -272,6 +276,9 @@
.mb-3 {
margin-bottom: calc(var(--spacing) * 3);
}
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
.mb-5 {
margin-bottom: calc(var(--spacing) * 5);
}
@@ -331,8 +338,8 @@
.w-full {
width: 100%;
}
- .max-w-3xl {
- max-width: var(--container-3xl);
+ .max-w-6xl {
+ max-width: var(--container-6xl);
}
.max-w-\[12rem\] {
max-width: 12rem;
@@ -470,6 +477,12 @@
border-color: color-mix(in oklab, var(--color-neutral-400) 30%, transparent);
}
}
+ .border-red-500\/30 {
+ border-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 30%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ border-color: color-mix(in oklab, var(--color-red-500) 30%, transparent);
+ }
+ }
.bg-accent {
background-color: var(--color-accent);
}
@@ -637,6 +650,9 @@
.text-red-600 {
color: var(--color-red-600);
}
+ .text-red-700 {
+ color: var(--color-red-700);
+ }
.text-white {
color: var(--color-white);
}
@@ -740,28 +756,28 @@
outline-style: none;
}
}
- .sm\:ml-auto {
- @media (width >= 40rem) {
- margin-left: auto;
- }
- }
.sm\:grid-cols-3 {
@media (width >= 40rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
- .sm\:flex-row {
- @media (width >= 40rem) {
+ .lg\:ml-auto {
+ @media (width >= 64rem) {
+ margin-left: auto;
+ }
+ }
+ .lg\:flex-row {
+ @media (width >= 64rem) {
flex-direction: row;
}
}
- .sm\:items-center {
- @media (width >= 40rem) {
+ .lg\:items-center {
+ @media (width >= 64rem) {
align-items: center;
}
}
- .sm\:gap-4 {
- @media (width >= 40rem) {
+ .lg\:gap-4 {
+ @media (width >= 64rem) {
gap: calc(var(--spacing) * 4);
}
}
@@ -833,6 +849,11 @@
color: var(--color-neutral-600);
}
}
+ .dark\:text-red-300 {
+ @media (prefers-color-scheme: dark) {
+ color: var(--color-red-300);
+ }
+ }
.dark\:text-red-400 {
@media (prefers-color-scheme: dark) {
color: var(--color-red-400);
diff --git a/pages/templates/components/navBar.gohtml b/pages/templates/components/navBar.gohtml
index 37f3fee..55fc992 100644
--- a/pages/templates/components/navBar.gohtml
+++ b/pages/templates/components/navBar.gohtml
@@ -1,7 +1,7 @@
{{ define "components/navBar" }}
-
+ {{ if .LoginError }}
+ {{ .LoginError }}
+ {{ end }}