summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/cmd/listend/main.go28
-rw-r--r--server/internal/api/api.go6
-rw-r--r--server/internal/api/api_test.go47
-rw-r--r--server/internal/store/store.go24
-rw-r--r--server/internal/store/tier.go85
-rw-r--r--server/internal/store/tier_test.go104
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)
+ }
+}