diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-10-31 08:39:44 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-10-31 08:39:44 +0000 |
| commit | cb3377b4856e3419df1cbc52044eda8bf68a006d (patch) | |
| tree | 939f55cf247f40b21009aa1f692dd7fe4b6faaf3 /server/internal | |
| parent | cbcd04cfb3c11ae0a171f4fce1954214baeff074 (diff) | |
Shows get a priority, so the news is not stuck behind a back catalogue
Peter's framing: a few shows are news and stop being worth hearing within a day,
a tier he keeps up with, and a long tail that is not topical at all. It matters
because recognising one episode costs the server about five minutes, so a
five-hour back-catalogue episode arriving the same morning as the news takes
most of a cycle before the news is looked at.
Three tiers and no more. A number would invite fiddling and would not mean
anything; these are the three distinctions that were described.
The tier rides beside the subscription list rather than inside it, so a server
that knows nothing about tiers reads the list exactly as before and a phone that
knows nothing keeps pushing what it always did. Only the shows placed somewhere
other than the default are sent -- "ordinary" for sixty shows is the same
information as nothing, in sixty times the space.
It orders both the poll and the recognition queue. Recognition now sorts on
three things in the order they matter: a topical show first, then an episode
whose answer checks itself against the length gap, then newest.
**A tier only exists for a subscribed feed**, which a test caught: the list and
the tiers arrive in one push, and setting the tiers after the list put back a
placement for a feed that same push had just removed. A resubscription months
later would have inherited it.
Unrecognised tiers are ignored at both ends and read as the default, which is
the one that starves nothing -- the same rule the label store follows for a
verdict it does not know. Schema 20, migrated and checked on a device.
The control says what it does not do as well as what it does: nothing about
playback changes, and a show placed anywhere still plays, downloads and skips
identically. A setting that looks like it changes playback and does not is worse
than no setting.
The coverage gate caught the push body having no test, which was fair -- it is
hand-built JSON going over the wire, where a missing brace is a library that
silently stops being mirrored. Its exact output is now pinned, including a URL
with a quote in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'server/internal')
| -rw-r--r-- | server/internal/api/api.go | 6 | ||||
| -rw-r--r-- | server/internal/api/api_test.go | 47 | ||||
| -rw-r--r-- | server/internal/store/store.go | 24 | ||||
| -rw-r--r-- | server/internal/store/tier.go | 85 | ||||
| -rw-r--r-- | server/internal/store/tier_test.go | 104 |
5 files changed, 261 insertions, 5 deletions
diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 2db0f91..4b7fb2d 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -169,12 +169,18 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) { } var body struct { Feeds []string `json:"feeds"` + // Tiers says how soon each feed's work is worth doing, keyed by URL. Optional and + // separate from the list rather than folded into it, so a phone that knows nothing + // about tiers keeps working unchanged. + Tiers map[string]string `json:"tiers,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected {\"feeds\":[...]}"}) return } removed := s.state.SetFeeds(body.Feeds) + // After the list, so a tier for a feed that was just removed is dropped with it. + s.state.SetTiers(body.Tiers) if s.opts.OnSubscriptions != nil { s.opts.OnSubscriptions(s.state.Feeds()) } diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index 19e40db..94f7ef4 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -371,3 +371,50 @@ func TestEpisodeResponseCarriesHowManyEpisodesSharedEachBreak(t *testing.T) { t.Errorf("partners %d, want 6", got.Breaks[0].Partners) } } + +/* +Where the phone places a show, and what happens when it does not. + +Tiers arrive beside the list rather than folded into it, so a phone that knows nothing about +them keeps working unchanged -- which every phone did until this week. +*/ + +func TestTakesTheTiersThePhoneSends(t *testing.T) { + s, st, _, _ := newServer(t) + + post(t, s, "/api/subscriptions", + `{"feeds":["https://a","https://b"],"tiers":{"https://a":"topical"}}`) + + if got := st.Tier("https://a"); got != store.TierTopical { + t.Errorf("got %q, want %q", got, store.TierTopical) + } + if got := st.Tier("https://b"); got != store.TierOrdinary { + t.Errorf("got %q, want the default", got) + } +} + +func TestAListWithNoTiersStillWorks(t *testing.T) { + s, st, _, _ := newServer(t) + + rec := post(t, s, "/api/subscriptions", `{"feeds":["https://a"]}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if len(st.Feeds()) != 1 { + t.Errorf("feeds = %v, want the list taken", st.Feeds()) + } +} + +// A tier for a feed the same push removed goes with it, rather than lingering for a +// resubscription months later to inherit. +func TestATierForARemovedFeedIsDropped(t *testing.T) { + s, st, _, _ := newServer(t) + post(t, s, "/api/subscriptions", `{"feeds":["https://a"],"tiers":{"https://a":"archive"}}`) + + post(t, s, "/api/subscriptions", `{"feeds":["https://b"],"tiers":{"https://a":"archive"}}`) + + if got := st.Tier("https://a"); got != store.TierOrdinary { + t.Errorf("got %q, want it dropped with the feed", got) + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 931cdd3..f69ff90 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -276,17 +276,22 @@ type State struct { // labels are the listener's own statements about stretches of audio. Not held on the // episodes: an episode leaves the mirror after eight and a label has to outlive it. labels []Label + // tiers is where the phone placed each feed: how soon its work is worth doing. Only the + // feeds placed somewhere other than the default are held, so the map stays the size of + // the exceptions rather than the size of the library. + tiers map[string]string } type persisted struct { - Feeds []string `json:"feeds"` - Shows map[string]*Show `json:"shows"` - Labels []Label `json:"labels,omitempty"` + Feeds []string `json:"feeds"` + Shows map[string]*Show `json:"shows"` + Labels []Label `json:"labels,omitempty"` + Tiers map[string]string `json:"tiers,omitempty"` } // New returns an empty state. func New() *State { - return &State{shows: map[string]*Show{}} + return &State{shows: map[string]*Show{}, tiers: map[string]string{}} } // Load reads state from disk. A missing file is a first run, not an error. @@ -308,6 +313,9 @@ func Load(path string) (*State, error) { if p.Shows != nil { s.shows = p.Shows } + if p.Tiers != nil { + s.tiers = p.Tiers + } return s, nil } @@ -318,7 +326,8 @@ func Load(path string) (*State, error) { // away hours of detection. func (s *State) Save(path string) error { s.mu.RLock() - raw, err := json.MarshalIndent(persisted{Feeds: s.feeds, Shows: s.shows, Labels: s.labels}, "", " ") + raw, err := json.MarshalIndent( + persisted{Feeds: s.feeds, Shows: s.shows, Labels: s.labels, Tiers: s.tiers}, "", " ") s.mu.RUnlock() if err != nil { return fmt.Errorf("save state: %w", err) @@ -374,6 +383,11 @@ func (s *State) SetFeeds(feeds []string) (removed []string) { removed = append(removed, url) } } + for url := range s.tiers { + if !wanted[url] { + s.forgetTier(url) + } + } sort.Strings(removed) s.feeds = clean return removed diff --git a/server/internal/store/tier.go b/server/internal/store/tier.go new file mode 100644 index 0000000..94d2634 --- /dev/null +++ b/server/internal/store/tier.go @@ -0,0 +1,85 @@ +package store + +import "sort" + +/* +How soon a show's work is worth doing. + +Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps +up with, and a long tail that is not topical at all. It matters because recognition costs about +five minutes of CPU an episode, so a five-hour Dan Carlin episode landing the same morning as Up +First takes most of a cycle's budget before the news is touched. + +Three and no more. A number would invite fiddling and would not mean anything; these are the +three distinctions he actually described. +*/ +const ( + // TierTopical is news: worth hearing today and worthless by Thursday. + TierTopical = "topical" + // TierOrdinary is a show kept up with. The default, because it is what most of a library + // is and because a show nobody has placed should not be starved. + TierOrdinary = "ordinary" + // TierArchive is a back catalogue, a slow drip, anything heard whenever. Its work waits + // for the time nothing else wants. + TierArchive = "archive" +) + +// tierRank orders the work. Lower goes first. +var tierRank = map[string]int{TierTopical: 0, TierOrdinary: 1, TierArchive: 2} + +/* +SetTiers records where the phone placed each feed, replacing what was there. + +Only for feeds that are actually subscribed. The phone sends its list and its tiers in one +push, and a tier naming a feed the same push dropped would otherwise outlive it and be +inherited by a resubscription months later. + +A tier this server does not understand is dropped rather than stored: a newer phone inventing a +fourth must not quietly stop a show being polled, which is the rule the label store already +follows for a verdict it does not recognise. +*/ +func (s *State) SetTiers(tiers map[string]string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.tiers == nil { + s.tiers = map[string]string{} + } + subscribed := make(map[string]bool, len(s.feeds)) + for _, url := range s.feeds { + subscribed[url] = true + } + for url, tier := range tiers { + _, known := tierRank[tier] + if !subscribed[url] || !known || tier == TierOrdinary { + delete(s.tiers, url) + continue + } + s.tiers[url] = tier + } +} + +// Tier is where a feed sits, or [TierOrdinary]. +func (s *State) Tier(feedURL string) string { + s.mu.RLock() + defer s.mu.RUnlock() + if tier, ok := s.tiers[feedURL]; ok { + return tier + } + return TierOrdinary +} + +// ByTier orders feed URLs so the topical ones are worked first and the archive last. +// +// Stable, so an ordering within a tier -- newest first, usually -- survives being grouped. A +// sort that reshuffled equals would make the queue unpredictable between cycles for no gain. +func (s *State) ByTier(feedURLs []string) []string { + out := append([]string(nil), feedURLs...) + sort.SliceStable(out, func(i, j int) bool { + return tierRank[s.Tier(out[i])] < tierRank[s.Tier(out[j])] + }) + return out +} + +// forgetTier drops a feed's placement. Called when the feed goes, or a feed resubscribed months +// later would arrive carrying a placement nobody chose. +func (s *State) forgetTier(feedURL string) { delete(s.tiers, feedURL) } diff --git a/server/internal/store/tier_test.go b/server/internal/store/tier_test.go new file mode 100644 index 0000000..971ee51 --- /dev/null +++ b/server/internal/store/tier_test.go @@ -0,0 +1,104 @@ +package store + +import "testing" + +/* +How soon a show's work is worth doing. + +Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps +up with, and a long tail that is not topical at all. Recognition costs about five minutes of CPU +an episode, so a five-hour Dan Carlin episode landing the same morning as Up First takes most of +a cycle's budget before the news is touched. + +Three tiers and no more. A number would invite fiddling and would not mean anything; these are +the three distinctions he actually described. +*/ + +func TestATierIsRememberedForAFeed(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://a", "https://b"}) + s.SetTiers(map[string]string{"https://a": TierTopical}) + + if got := s.Tier("https://a"); got != TierTopical { + t.Errorf("got %q, want %q", got, TierTopical) + } +} + +// A show nobody has placed is in the middle: worth keeping up with, not urgent. +func TestAShowWithNoTierIsOrdinary(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://a"}) + + if got := s.Tier("https://a"); got != TierOrdinary { + t.Errorf("got %q, want %q", got, TierOrdinary) + } +} + +/* +A tier this server does not understand is ignored rather than stored. + +A newer phone inventing a fourth tier must not quietly stop a show being polled at all -- the +same rule the label store already follows for a verdict it does not recognise. +*/ +func TestAnUnknownTierIsIgnored(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://a"}) + s.SetTiers(map[string]string{"https://a": "urgent-ish"}) + + if got := s.Tier("https://a"); got != TierOrdinary { + t.Errorf("got %q, want the ordinary tier", got) + } +} + +// Unsubscribing takes the tier with it, or a feed resubscribed months later would arrive +// carrying a placement nobody chose. +func TestForgettingAFeedForgetsItsTier(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://a"}) + s.SetTiers(map[string]string{"https://a": TierTopical}) + s.SetFeeds([]string{"https://b"}) + + if got := s.Tier("https://a"); got != TierOrdinary { + t.Errorf("got %q, want the tier forgotten with the feed", got) + } +} + +/* +Ordering runs topical first and archive last, and keeps the given order within a tier. + +Stable so that "newest first" survives being grouped: an ordering that reshuffled equals would +make the queue unpredictable from one cycle to the next for no gain. +*/ +func TestWorkIsOrderedByTier(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://news", "https://usual", "https://archive", "https://news2"}) + s.SetTiers(map[string]string{ + "https://news": TierTopical, + "https://news2": TierTopical, + "https://archive": TierArchive, + }) + + got := s.ByTier([]string{"https://usual", "https://archive", "https://news", "https://news2"}) + want := []string{"https://news", "https://news2", "https://usual", "https://archive"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +/* +A tier for a feed that is not subscribed is not kept. + +The phone sends its list and its tiers in one push, so a tier naming a feed the same push +dropped would outlive it and be inherited by a resubscription months later. +*/ +func TestATierForAFeedNotSubscribedIsDropped(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://b"}) + s.SetTiers(map[string]string{"https://a": TierArchive}) + + if got := s.Tier("https://a"); got != TierOrdinary { + t.Errorf("got %q, want nothing kept for an unsubscribed feed", got) + } +} |
