diff options
Diffstat (limited to 'server')
| -rw-r--r-- | server/cmd/listend/main.go | 28 | ||||
| -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 |
6 files changed, 288 insertions, 6 deletions
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go index 377fedc..43921bd 100644 --- a/server/cmd/listend/main.go +++ b/server/cmd/listend/main.go @@ -40,6 +40,18 @@ import ( "github.com/terst/listen/server/internal/transcript" ) +// tierRankOf orders the tiers for work: topical first, archive last. +func tierRankOf(tier string) int { + switch tier { + case store.TierTopical: + return 0 + case store.TierArchive: + return 2 + default: + return 1 + } +} + // minRecurringCorpus is how many of a show's episodes must have words before what they share // means anything. Three: the target and the two others a stretch has to turn up in. const minRecurringCorpus = 3 @@ -291,7 +303,9 @@ func (w *worker) save() { // pollAll refreshes every subscribed feed, then mirrors and detects what changed. func (w *worker) pollAll(ctx context.Context) { - feeds := w.state.Feeds() + // Topical shows first and archive last, so a five-hour back-catalogue episode cannot take + // a cycle's recognition budget before the morning news has been looked at. + feeds := w.state.ByTier(w.state.Feeds()) if len(feeds) == 0 { log.Printf("no subscriptions yet; waiting for the phone to send its list") return @@ -960,8 +974,20 @@ func (w *worker) pendingRecognition() []*store.Episode { } } } + // Three things decide the order, and they are in the order they matter. A topical show is + // worthless by Thursday. An episode whose answer checks itself against the length gap is + // worth more than one that cannot. And within that, newest first. + rank := map[string]int{} + for _, show := range w.state.Shows() { + for _, e := range show.Episodes { + rank[e.ID] = tierRankOf(w.state.Tier(show.FeedURL)) + } + } sort.SliceStable(pending, func(i, j int) bool { left, right := pending[i], pending[j] + if rank[left.ID] != rank[right.ID] { + return rank[left.ID] < rank[right.ID] + } if (left.MasterCues > 0) != (right.MasterCues > 0) { return left.MasterCues > 0 } 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) + } +} |
