diff --git a/appview/database/migrations/0002_episode_states.sql b/appview/database/migrations/0002_episode_states.sql new file mode 100644 index 0000000..c3400ca --- /dev/null +++ b/appview/database/migrations/0002_episode_states.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS episode_states ( + id BIGSERIAL PRIMARY KEY, + did VARCHAR(255) NOT NULL, + rkey VARCHAR(512) NOT NULL, + feed_id INTEGER NOT NULL CHECK (feed_id > 0), + episode_id INTEGER NOT NULL CHECK (episode_id > 0), + episode_guid VARCHAR(512), + podcast_guid VARCHAR(512), + position_s INTEGER, + duration_s INTEGER, + played BOOLEAN NOT NULL DEFAULT FALSE, + saved BOOLEAN NOT NULL DEFAULT FALSE, + hidden BOOLEAN NOT NULL DEFAULT FALSE, + updated_at VARCHAR(64), + created_at VARCHAR(64) NOT NULL, + indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_episode_states_did_rkey ON episode_states (did, rkey); +CREATE INDEX IF NOT EXISTS idx_episode_states_did ON episode_states (did); +CREATE UNIQUE INDEX IF NOT EXISTS idx_episode_states_episode ON episode_states (did, feed_id, episode_id); +CREATE INDEX IF NOT EXISTS idx_episode_states_saved ON episode_states (did, saved); diff --git a/appview/database/models.go b/appview/database/models.go index 0ad60d3..0b49604 100644 --- a/appview/database/models.go +++ b/appview/database/models.go @@ -127,3 +127,23 @@ type EpisodeStats struct { } func (EpisodeStats) TableName() string { return "episode_stats" } + +type EpisodeState struct { + ID uint `gorm:"primaryKey"` + DID string `gorm:"size:255;not null;index:idx_episode_states_did_rkey,unique;index:idx_episode_states_did"` + Rkey string `gorm:"size:512;not null;index:idx_episode_states_did_rkey,unique"` + FeedID int `gorm:"not null;index:idx_episode_states_episode,unique"` + EpisodeID int `gorm:"not null;index:idx_episode_states_episode,unique"` + EpisodeGuid string `gorm:"size:512"` + PodcastGuid string `gorm:"size:512"` + PositionS *int + DurationS *int + Played bool `gorm:"default:false"` + Saved bool `gorm:"default:false"` + Hidden bool `gorm:"default:false"` + UpdatedAt string `gorm:"size:64"` + CreatedAt string `gorm:"size:64;not null"` + IndexedAt time.Time `gorm:"autoCreateTime"` +} + +func (EpisodeState) TableName() string { return "episode_states" } diff --git a/appview/handlers/episode_state.go b/appview/handlers/episode_state.go new file mode 100644 index 0000000..3f18a6b --- /dev/null +++ b/appview/handlers/episode_state.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "net/http" + + "tangled.org/sparrowtek.com/effem-AppView/appview/database" + "github.com/labstack/echo/v4" +) + +func (h *Handlers) GetEpisodeStates(c echo.Context) error { + did := c.QueryParam("did") + if did == "" { + return writeError(c, http.StatusBadRequest, "InvalidRequest", "did is required") + } + + feedID := parseInt(c.QueryParam("feedId"), 0) + limit := parseLimit(c.QueryParam("limit"), 50, 100) + cursor := c.QueryParam("cursor") + + q := h.db.WithContext(c.Request().Context()).Model(&database.EpisodeState{}).Where("did = ?", did).Order("rkey DESC").Limit(limit + 1) + + if feedID > 0 { + q = q.Where("feed_id = ?", feedID) + } + if c.QueryParam("saved") == "true" { + q = q.Where("saved = ?", true) + } + if c.QueryParam("hidden") != "true" { + q = q.Where("hidden = ?", false) + } + if cursor != "" { + q = q.Where("rkey < ?", cursor) + } + + var rows []database.EpisodeState + if err := q.Find(&rows).Error; err != nil { + return writeError(c, http.StatusInternalServerError, "InternalError", "") + } + + nextCursor := "" + if len(rows) > limit { + nextCursor = rows[limit-1].Rkey + rows = rows[:limit] + } + + return c.JSON(http.StatusOK, map[string]any{"episodeStates": rows, "cursor": nextCursor}) +} diff --git a/appview/indexer/episode_state.go b/appview/indexer/episode_state.go new file mode 100644 index 0000000..345d173 --- /dev/null +++ b/appview/indexer/episode_state.go @@ -0,0 +1,46 @@ +package indexer + +import ( + "context" + "fmt" + + "tangled.org/sparrowtek.com/effem-AppView/appview/database" +) + +func (idx *Indexer) indexEpisodeState(ctx context.Context, did, rkey string, rec map[string]any) error { + episode, ok := asMap(rec["episode"]) + if !ok { + return fmt.Errorf("episodeState missing episode object") + } + feedID, ok := asInt(episode["feedId"]) + if !ok || feedID <= 0 { + return fmt.Errorf("episodeState missing valid episode.feedId") + } + episodeID, ok := asInt(episode["episodeId"]) + if !ok || episodeID <= 0 { + return fmt.Errorf("episodeState missing valid episode.episodeId") + } + + state := database.EpisodeState{ + DID: did, + Rkey: rkey, + FeedID: feedID, + EpisodeID: episodeID, + EpisodeGuid: asString(episode["episodeGuid"]), + PodcastGuid: asString(episode["podcastGuid"]), + Played: asBool(rec["played"]), + Saved: asBool(rec["saved"]), + Hidden: asBool(rec["hidden"]), + CreatedAt: asString(rec["createdAt"]), + UpdatedAt: asString(rec["updatedAt"]), + } + if pos, ok := asInt(rec["positionS"]); ok { + state.PositionS = &pos + } + if dur, ok := asInt(rec["durationS"]); ok { + state.DurationS = &dur + } + + db := idx.db.WithContext(ctx) + return db.Where("did = ? AND rkey = ?", did, rkey).Assign(state).FirstOrCreate(&state).Error +} diff --git a/appview/indexer/indexer.go b/appview/indexer/indexer.go index 76b83c7..0ca53ac 100644 --- a/appview/indexer/indexer.go +++ b/appview/indexer/indexer.go @@ -37,6 +37,8 @@ func (idx *Indexer) IndexRecord(ctx context.Context, did, collection, rkey strin return idx.indexList(ctx, did, rkey, rec) case "xyz.effem.feed.bookmark": return idx.indexBookmark(ctx, did, rkey, rec) + case "xyz.effem.feed.episodeState": + return idx.indexEpisodeState(ctx, did, rkey, rec) case "xyz.effem.actor.profile": return idx.indexProfile(ctx, did, rec) default: @@ -92,6 +94,8 @@ func (idx *Indexer) DeleteRecord(ctx context.Context, did, collection, rkey stri return err } return idx.refreshEpisodeStats(ctx, row.FeedID, row.EpisodeID) + case "xyz.effem.feed.episodeState": + return db.Where("did = ? AND rkey = ?", did, rkey).Delete(&database.EpisodeState{}).Error case "xyz.effem.actor.profile": return db.Where("did = ?", did).Delete(&database.Profile{}).Error default: @@ -141,6 +145,11 @@ func asInt(v any) (int, bool) { } } +func asBool(v any) bool { + b, ok := v.(bool) + return ok && b +} + func jsonBytes(v any) []byte { b, err := json.Marshal(v) if err != nil { diff --git a/appview/server.go b/appview/server.go index d1adcc7..5b0510d 100644 --- a/appview/server.go +++ b/appview/server.go @@ -113,6 +113,7 @@ func (srv *Server) registerRoutes() { xrpc.GET("/xyz.effem.feed.getLists", h.GetLists, httpmw.RequireQueryDID("did")) xrpc.GET("/xyz.effem.feed.getBookmarks", h.GetBookmarks, httpmw.RequireQueryDID("did")) + xrpc.GET("/xyz.effem.feed.getEpisodeStates", h.GetEpisodeStates, httpmw.RequireQueryDID("did")) xrpc.GET("/xyz.effem.actor.getProfile", h.GetProfile) diff --git a/lexicons/xyz/effem/feed/episodeState.json b/lexicons/xyz/effem/feed/episodeState.json new file mode 100644 index 0000000..74facb0 --- /dev/null +++ b/lexicons/xyz/effem/feed/episodeState.json @@ -0,0 +1,52 @@ +{ + "lexicon": 1, + "id": "xyz.effem.feed.episodeState", + "defs": { + "main": { + "type": "record", + "description": "Per-user, per-episode playback and library state. The rkey is the Podcast Index episode ID, giving one record per episode per user.", + "key": "any", + "record": { + "type": "object", + "required": ["episode", "createdAt"], + "properties": { + "episode": { + "type": "ref", + "ref": "xyz.effem.feed.defs#episodeRef" + }, + "positionS": { + "type": "integer", + "minimum": 0, + "description": "Playback position in seconds." + }, + "durationS": { + "type": "integer", + "minimum": 0, + "description": "Total episode duration in seconds." + }, + "played": { + "type": "boolean", + "description": "Explicitly marked as fully played." + }, + "saved": { + "type": "boolean", + "description": "Saved to library/queue." + }, + "hidden": { + "type": "boolean", + "description": "Archived/dismissed by user." + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "updatedAt": { + "type": "string", + "format": "datetime", + "description": "When state was last modified." + } + } + } + } + } +}