diff options
| author | Xe Iaso <me@xeiaso.net> | 2024-09-02 09:33:04 -0400 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2024-09-02 09:33:04 -0400 |
| commit | 4628a6e4ba4920925bef6b5dbb6dfd15c7b08a73 (patch) | |
| tree | 34aefa02776bec947c3cb6f38b7c1fd69f7a9d2f /internal | |
| parent | a2cf1050866bc902ad014ab21767531ff64a337a (diff) | |
| download | x-4628a6e4ba4920925bef6b5dbb6dfd15c7b08a73.tar.xz x-4628a6e4ba4920925bef6b5dbb6dfd15c7b08a73.zip | |
import cmd/aerial
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/pvfm/bot/bot.go | 181 | ||||
| -rw-r--r-- | internal/pvfm/bot/doc.go | 10 | ||||
| -rw-r--r-- | internal/pvfm/bot/help.go | 38 | ||||
| -rw-r--r-- | internal/pvfm/commands/source/source.go | 8 | ||||
| -rw-r--r-- | internal/pvfm/fname.go | 28 | ||||
| -rw-r--r-- | internal/pvfm/info.go | 105 | ||||
| -rw-r--r-- | internal/pvfm/pvl/pvl.go | 77 | ||||
| -rw-r--r-- | internal/pvfm/recording/doc.go | 4 | ||||
| -rw-r--r-- | internal/pvfm/recording/recording.go | 120 | ||||
| -rw-r--r-- | internal/pvfm/recording/recording_demo.go | 59 | ||||
| -rw-r--r-- | internal/pvfm/schedule/schedule.go | 102 | ||||
| -rw-r--r-- | internal/pvfm/station/station.go | 96 |
12 files changed, 828 insertions, 0 deletions
diff --git a/internal/pvfm/bot/bot.go b/internal/pvfm/bot/bot.go new file mode 100644 index 0000000..5ed093d --- /dev/null +++ b/internal/pvfm/bot/bot.go @@ -0,0 +1,181 @@ +package bot + +import ( + "errors" + "log" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" + + "github.com/bwmarrin/discordgo" +) + +var ( + ErrRateLimitExceeded = errors.New("bot: per-command rate limit exceeded") +) + +type command struct { + aliases []string + verb string + helptext string +} + +func (c *command) Verb() string { + return c.verb +} + +func (c *command) Helptext() string { + return c.helptext +} + +// Handler is the type that bot command functions need to implement. Errors +// should be returned. +type Handler func(*discordgo.Session, *discordgo.Message, []string) error + +// CommandHandler is a generic interface for types that implement a bot +// command. It is akin to http.Handler, but more comprehensive. +type CommandHandler interface { + Verb() string + Helptext() string + + Handler(*discordgo.Session, *discordgo.Message, []string) error + Permissions(*discordgo.Session, *discordgo.Message, []string) error +} + +type basicCommand struct { + *command + handler Handler + permissions Handler + limiter *rate.Limiter +} + +func (bc *basicCommand) Handler(s *discordgo.Session, m *discordgo.Message, parv []string) error { + return bc.handler(s, m, parv) +} + +func (bc *basicCommand) Permissions(s *discordgo.Session, m *discordgo.Message, parv []string) error { + if !bc.limiter.Allow() { + return ErrRateLimitExceeded + } + + return bc.permissions(s, m, parv) +} + +// The "default" command set, useful for simple bot projects. +var ( + DefaultCommandSet = NewCommandSet() +) + +// Command handling errors. +var ( + ErrAlreadyExists = errors.New("bot: command already exists") + ErrNoSuchCommand = errors.New("bot: no such command exists") + ErrNoPermissions = errors.New("bot: you do not have permissions for this command") + ErrParvCountMismatch = errors.New("bot: parameter count mismatch") +) + +// The default command prefix. Command `foo` becomes `.foo` in chat, etc. +const ( + DefaultPrefix = "." +) + +// NewCommand creates an anonymous command and adds it to the default CommandSet. +func NewCommand(verb, helptext string, handler, permissions Handler) error { + return DefaultCommandSet.Add(NewBasicCommand(verb, helptext, handler, permissions)) +} + +// NewBasicCommand creates a CommandHandler instance using the implementation +// functions supplied as arguments. +func NewBasicCommand(verb, helptext string, permissions, handler Handler) CommandHandler { + return &basicCommand{ + command: &command{ + verb: verb, + helptext: helptext, + }, + handler: handler, + permissions: permissions, + limiter: rate.NewLimiter(rate.Every(5*time.Second), 1), + } +} + +// CommandSet is a group of bot commands similar to an http.ServeMux. +type CommandSet struct { + sync.Mutex + cmds map[string]CommandHandler + + Prefix string +} + +// NewCommandSet creates a new command set with the `help` command pre-loaded. +func NewCommandSet() *CommandSet { + cs := &CommandSet{ + cmds: map[string]CommandHandler{}, + Prefix: DefaultPrefix, + } + + cs.AddCmd("help", "Shows help for the bot", NoPermissions, cs.help) + + return cs +} + +// NoPermissions is a simple middelware function that allows all command invocations +// to pass the permissions check. +func NoPermissions(s *discordgo.Session, m *discordgo.Message, parv []string) error { + return nil +} + +// AddCmd is syntactic sugar for cs.Add(NewBasicCommand(args...)) +func (cs *CommandSet) AddCmd(verb, helptext string, permissions, handler Handler) error { + return cs.Add(NewBasicCommand(verb, helptext, permissions, handler)) +} + +// Add adds a single command handler to the CommandSet. This can be done at runtime +// but it is suggested to only add commands on application boot. +func (cs *CommandSet) Add(h CommandHandler) error { + cs.Lock() + defer cs.Unlock() + + v := strings.ToLower(h.Verb()) + + if _, ok := cs.cmds[v]; ok { + return ErrAlreadyExists + } + + cs.cmds[v] = h + + return nil +} + +// Run makes a CommandSet compatible with discordgo event dispatching. +func (cs *CommandSet) Run(s *discordgo.Session, msg *discordgo.Message) error { + cs.Lock() + defer cs.Unlock() + + if strings.HasPrefix(msg.Content, cs.Prefix) { + params := strings.Fields(msg.Content) + verb := strings.ToLower(params[0][1:]) + + cmd, ok := cs.cmds[verb] + if !ok { + return ErrNoSuchCommand + } + + err := cmd.Permissions(s, msg, params) + if err != nil { + log.Printf("Permissions error: %s: %v", msg.Author.Username, err) + s.ChannelMessageSend(msg.ChannelID, "You don't have permissions for that, sorry.") + return ErrNoPermissions + } + + err = cmd.Handler(s, msg, params) + if err != nil { + log.Printf("command handler error: %v", err) + s.ChannelMessageSend(msg.ChannelID, "error when running that command: "+err.Error()) + return err + } + } + + return nil +} diff --git a/internal/pvfm/bot/doc.go b/internal/pvfm/bot/doc.go new file mode 100644 index 0000000..d5c5af5 --- /dev/null +++ b/internal/pvfm/bot/doc.go @@ -0,0 +1,10 @@ +/* +Package bot contains some generically useful bot rigging for Discord chatbots. + +This package works by defining command handlers in a CommandSet, and then dispatching +based on message contents. If the bot's command prefix is `;`, then `;foo` activates +the command handler for command `foo`. + +A CommandSet has a mutex baked into it for convenience of command implementation. +*/ +package bot diff --git a/internal/pvfm/bot/help.go b/internal/pvfm/bot/help.go new file mode 100644 index 0000000..7e62ff2 --- /dev/null +++ b/internal/pvfm/bot/help.go @@ -0,0 +1,38 @@ +package bot + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func (cs *CommandSet) help(s *discordgo.Session, m *discordgo.Message, parv []string) error { + switch len(parv) { + case 1: + result := cs.formHelp() + + authorChannel, err := s.UserChannelCreate(m.Author.ID) + if err != nil { + return err + } + + s.ChannelMessageSend(authorChannel.ID, result) + + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("<@%s> check direct messages, help is there!", m.Author.ID)) + + default: + return ErrParvCountMismatch + } + + return nil +} + +func (cs *CommandSet) formHelp() string { + result := "Bot commands: \n" + + for verb, cmd := range cs.cmds { + result += fmt.Sprintf("%s%s: %s\n", cs.Prefix, verb, cmd.Helptext()) + } + + return (result + "If there's any problems please don't hesitate to ask a server admin for help.") +} diff --git a/internal/pvfm/commands/source/source.go b/internal/pvfm/commands/source/source.go new file mode 100644 index 0000000..d72f70c --- /dev/null +++ b/internal/pvfm/commands/source/source.go @@ -0,0 +1,8 @@ +package source + +import "github.com/bwmarrin/discordgo" + +func Source(s *discordgo.Session, m *discordgo.Message, parv []string) error { + s.ChannelMessageSend(m.ChannelID, "Source code: https://github.com/PonyvilleFM/aura") + return nil +} diff --git a/internal/pvfm/fname.go b/internal/pvfm/fname.go new file mode 100644 index 0000000..fa84695 --- /dev/null +++ b/internal/pvfm/fname.go @@ -0,0 +1,28 @@ +package pvfm + +import ( + "fmt" + "strings" + "time" + + "within.website/x/internal/pvfm/pvl" +) + +func GenFilename() (string, error) { + cal, err := pvl.Get() + if err != nil { + return "", nil + } + + now := cal.Result[0] + + localTime := time.Now() + thentime := time.Unix(now.StartTime, 0) + if thentime.Unix() < localTime.Unix() { + // return fmt.Sprintf("%s - %s.mp3", now.Title, localTime.Format(time.RFC822)), nil + } + + now.Title = strings.Replace(now.Title, "/", "-slash-", 0) + + return fmt.Sprintf("%s - %s.mp3", now.Title, localTime.Format(time.RFC822)), nil +} diff --git a/internal/pvfm/info.go b/internal/pvfm/info.go new file mode 100644 index 0000000..4c3456e --- /dev/null +++ b/internal/pvfm/info.go @@ -0,0 +1,105 @@ +/* +Package pvfm grabs information about PonyvilleFM from the station servers. +*/ +package pvfm + +import ( + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "net/http" + "regexp" + "time" +) + +var ( + latestInfo Wrapper + + bugTime = flag.Int("pvfm-poke-delay", 15, "how stale the info can get") +) + +// The regex to check for Aerial's name. +var ( + AerialRegex = regexp.MustCompile(`Aerial`) +) + +// Wrapper is a time, info pair. This is used to invalidate the cache of +// data from ponyvillefm.com. +type Wrapper struct { + Age time.Time + Info Info +} + +// Info is the actual information we care about. It contains information about the +// available streams. +type Info struct { + Listeners Listeners `json:"all"` + Main RadioStream `json:"one"` + Secondary RadioStream `json:"two"` + MusicOnly RadioStream `json:"free"` +} + +// RadioStream contains data about an individual stream. +type RadioStream struct { + Listeners int `json:"listeners"` + Nowplaying string `json:"nowplaying"` + Artist string `json:"artist"` + Album string `json:"album"` + Title string `json:"title"` + Onair string `json:"onair"` + Artwork string `json:"artwork"` +} + +// Listeners contains a single variable Listeners. +type Listeners struct { + Listeners int `json:"listeners"` +} + +// GetStats returns an Info, error pair representing the latest (or cached) +// version of the statistics from the ponyvillefm servers. If there is an error +// anywhere +func GetStats() (Info, error) { + now := time.Now() + + // If right now is before the age of the latestInfo plus the pacing time, + // return the latestInfo Info. + if now.Before(latestInfo.Age.Add(time.Second * time.Duration(*bugTime))) { + return latestInfo.Info, nil + } + + i := Info{} + + // Grab stuff from the internet + c := &http.Client{ + Timeout: time.Second * 15, + } + + resp, err := c.Get("http://ponyvillefm.com/data/nowplaying") + if err != nil { + return Info{}, fmt.Errorf("http fetch: %s %d: %v", resp.Status, resp.StatusCode, err) + } + defer resp.Body.Close() + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return Info{}, err + } + + err = json.Unmarshal(content, &i) + if err != nil { + return Info{}, fmt.Errorf("json unmarshal: %v", err) + } + + // Update the age/contents of the latestInfo + latestInfo.Info = i + latestInfo.Age = now + + return latestInfo.Info, nil +} + +// IsDJLive returns true if a human DJ is live or false if the auto DJ (and any +// of its playlists) is playing music. +func (i Info) IsDJLive() bool { + return !AerialRegex.Match([]byte(i.Main.Onair)) +} diff --git a/internal/pvfm/pvl/pvl.go b/internal/pvfm/pvl/pvl.go new file mode 100644 index 0000000..cb8e80b --- /dev/null +++ b/internal/pvfm/pvl/pvl.go @@ -0,0 +1,77 @@ +/* +Package pvl grabs Ponyville Live data. +*/ +package pvl + +import ( + "encoding/json" + "errors" + "flag" + "io/ioutil" + "net/http" + "strconv" + "time" +) + +var ( + latestInfo Wrapper + + bugTime = flag.Int64("pvl-poke-delay", 666, "how stale pvl info can get") +) + +type Wrapper struct { + Age time.Time + Info Calendar +} + +type Calendar struct { + Result []struct { + Body interface{} `json:"body"` + EndTime int64 `json:"end_time"` + Guid string `json:"guid"` + ID float64 `json:"id"` + ImageURL string `json:"image_url"` + IsAllDay bool `json:"is_all_day"` + IsPromoted bool `json:"is_promoted"` + Location interface{} `json:"location"` + Range string `json:"range"` + StartTime int64 `json:"start_time"` + StationID int64 `json:"station_id"` + Title string `json:"title"` + WebURL string `json:"web_url"` + } `json:"result"` + Status string `json:"status"` +} + +// Get grabs the station schedule from Ponyville Live. +func Get() (Calendar, error) { + now := time.Now() + if now.Before(latestInfo.Age.Add(time.Second * time.Duration(*bugTime))) { + return latestInfo.Info, nil + } + + c := Calendar{} + resp, err := http.Get("http://ponyvillelive.com/api/schedule/index/station/ponyvillefm") + if err != nil { + return Calendar{}, err + } + defer resp.Body.Close() + + if resp.StatusCode/100 == 5 { + return Calendar{}, errors.New("pvl: API returned " + strconv.Itoa(resp.StatusCode) + " " + resp.Status) + } + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return Calendar{}, err + } + + err = json.Unmarshal(content, &c) + if err != nil { + return Calendar{}, err + } + + latestInfo.Info = c + latestInfo.Age = now + return latestInfo.Info, nil +} diff --git a/internal/pvfm/recording/doc.go b/internal/pvfm/recording/doc.go new file mode 100644 index 0000000..95bd800 --- /dev/null +++ b/internal/pvfm/recording/doc.go @@ -0,0 +1,4 @@ +/* +Package recording manages recording radio streams to files. +*/ +package recording diff --git a/internal/pvfm/recording/recording.go b/internal/pvfm/recording/recording.go new file mode 100644 index 0000000..d3378ce --- /dev/null +++ b/internal/pvfm/recording/recording.go @@ -0,0 +1,120 @@ +package recording + +import ( + "context" + "errors" + "log" + "os" + "os/exec" + "time" +) + +var ( + ErrMismatchWrite = errors.New("recording: did not write the same number of bytes that were read") +) + +// Recording ... +type Recording struct { + ctx context.Context + url string + fname string + cancel context.CancelFunc + started time.Time + restarts int + + Debug bool + Err error +} + +// New creates a new Recording of the given URL to the given filename for output. +func New(url, fname string) (*Recording, error) { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Hour) + + r := &Recording{ + ctx: ctx, + url: url, + fname: fname, + cancel: cancel, + started: time.Now(), + } + + return r, nil +} + +// Cancel stops the recording. +func (r *Recording) Cancel() { + r.cancel() +} + +// Done returns the done channel of the recording. +func (r *Recording) Done() <-chan struct{} { + return r.ctx.Done() +} + +// OutputFilename gets the output filename originally passed into New. +func (r *Recording) OutputFilename() string { + return r.fname +} + +// StartTime gets start time +func (r *Recording) StartTime() time.Time { + return r.started +} + +// Start blockingly starts the recording and returns the error if one is encountered while streaming. +// This should be stopped in another goroutine. +func (r *Recording) Start() error { + sr, err := exec.LookPath("streamripper") + if err != nil { + return err + } + + cmd := exec.CommandContext(r.ctx, sr, r.url, "-A", "-a", r.fname) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + + log.Printf("%s: %v", cmd.Path, cmd.Args) + + err = cmd.Start() + if err != nil { + return err + } + + // Automatically kill recordings after four hours + go func() { + t := time.NewTicker(4 * time.Hour) + defer t.Stop() + + log.Println("got here") + + for { + select { + case <-r.ctx.Done(): + return + case <-t.C: + log.Printf("Automatically killing recording after 4 hours...") + r.Cancel() + } + } + }() + + go func() { + defer r.Cancel() + err := cmd.Wait() + if err != nil { + log.Println(err) + } + }() + + defer r.cancel() + + for { + time.Sleep(250 * time.Millisecond) + + select { + case <-r.ctx.Done(): + return nil + default: + } + } +} diff --git a/internal/pvfm/recording/recording_demo.go b/internal/pvfm/recording/recording_demo.go new file mode 100644 index 0000000..3264841 --- /dev/null +++ b/internal/pvfm/recording/recording_demo.go @@ -0,0 +1,59 @@ +// +build ignore + +package main + +import ( + "flag" + "log" + "os" + "os/signal" + + "github.com/PonyvilleFM/aura/recording" +) + +var ( + url = flag.String("url", "", "url to record") + fname = flag.String("fname", "", "filename to record to") + debug = flag.Bool("debug", false, "debug mode") + + askedToDie bool +) + +func main() { + flag.Parse() + + r, err := recording.New(*url, *fname) + if err != nil { + log.Printf("%s -> %s: %v", *url, *fname, err) + log.Fatal(err) + } + + r.Debug = *debug + + go func() { + log.Printf("Starting download of stream %s to %s", *url, *fname) + err := r.Start() + if err != nil { + log.Fatal(err) + } + }() + + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + + for _ = range c { + if askedToDie { + os.Exit(2) + } + + log.Println("Stopping recording... (^C again to kill now)") + r.Cancel() + + askedToDie = true + } + }() + + <-r.Done() + log.Printf("stream %s recorded to %s", *url, *fname) +} diff --git a/internal/pvfm/schedule/schedule.go b/internal/pvfm/schedule/schedule.go new file mode 100644 index 0000000..982460c --- /dev/null +++ b/internal/pvfm/schedule/schedule.go @@ -0,0 +1,102 @@ +/* +Package schedule grabs DJ schedule data from Ponyville FM's servers. +*/ +package schedule + +import ( + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +// ScheduleResult is a wrapper for a list of ScheduleEntry records. +type ScheduleResult struct { + Result []ScheduleEntry `json:"result"` +} + +// ScheduleEntry is an individual schedule datum. +type ScheduleEntry struct { + StartTime string `json:"start_time"` + StartUnix int `json:"start_unix"` + Duration int `json:"duration"` // minutes + EndTime string `json:"end_time"` + EndUnix int `json:"end_unix"` + Name string `json:"name"` + Host string `json:"host"` + Description string `json:"description"` + Showcard string `json:"showcard"` + Background string `json:"background"` + Timezone string `json:"timezone"` + Status string `json:"status"` +} + +func (s ScheduleEntry) String() string { + startTimeUnix := time.Unix(int64(s.StartUnix), 0) + nowWithoutNanoseconds := time.Unix(time.Now().Unix(), 0) + dur := startTimeUnix.Sub(nowWithoutNanoseconds) + + if dur > 0 { + return fmt.Sprintf( + "In %s (%v %v): %s - %s", + dur, s.StartTime, s.Timezone, s.Host, s.Name, + ) + } else { + return fmt.Sprintf( + "Now: %s - %s", + s.Host, s.Name, + ) + } +} + +var ( + latestInfo Wrapper + + bugTime = flag.Int("pvfm-schedule-poke-delay", 15, "how stale the info can get") +) + +// Wrapper is a time, info pair. This is used to invalidate the cache of +// data from ponyvillefm.com. +type Wrapper struct { + Age time.Time + Info *ScheduleResult +} + +// Get returns schedule entries, only fetching new data at most every n +// seconds, where n is defined above. +func Get() ([]ScheduleEntry, error) { + now := time.Now() + + if now.Before(latestInfo.Age.Add(time.Second * time.Duration(*bugTime))) { + return latestInfo.Info.Result, nil + } + + s := &ScheduleResult{} + c := http.Client{ + Timeout: time.Duration(time.Second * 15), + } + + resp, err := c.Get("http://ponyvillefm.com/data/schedule") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(content, s) + if err != nil { + return nil, err + } + + // Update the age/contents of the latestInfo + latestInfo.Info = s + latestInfo.Age = now + + return s.Result, nil +} diff --git a/internal/pvfm/station/station.go b/internal/pvfm/station/station.go new file mode 100644 index 0000000..f6e9cd6 --- /dev/null +++ b/internal/pvfm/station/station.go @@ -0,0 +1,96 @@ +/* +Package station grabs fallback data from the radio station. +*/ +package station + +import ( + "encoding/json" + "flag" + "io/ioutil" + "net/http" + "time" +) + +var ( + latestInfo Wrapper + + bugTime = flag.Int("station-poke-delay", 15, "how stale the info can get") +) + +type Wrapper struct { + Age time.Time + Info Info +} + +type Info struct { + Icestats struct { + Admin string `json:"admin"` + BannedIPs int `json:"banned_IPs"` + Build string `json:"build"` + Host string `json:"host"` + Location string `json:"location"` + OutgoingKbitrate int `json:"outgoing_kbitrate"` + ServerID string `json:"server_id"` + ServerStart string `json:"server_start"` + StreamKbytesRead int `json:"stream_kbytes_read"` + StreamKbytesSent int `json:"stream_kbytes_sent"` + Source []struct { + Artist string `json:"artist"` + AudioBitrate int `json:"audio_bitrate,omitempty"` + AudioChannels int `json:"audio_channels,omitempty"` + AudioInfo string `json:"audio_info"` + AudioSamplerate int `json:"audio_samplerate,omitempty"` + Bitrate int `json:"bitrate"` + Connected int `json:"connected"` + Genre string `json:"genre"` + IceBitrate int `json:"ice-bitrate,omitempty"` + IncomingBitrate int `json:"incoming_bitrate"` + ListenerPeak int `json:"listener_peak"` + Listeners int `json:"listeners"` + Listenurl string `json:"listenurl"` + MetadataUpdated string `json:"metadata_updated"` + OutgoingKbitrate int `json:"outgoing_kbitrate"` + QueueSize int `json:"queue_size"` + ServerDescription string `json:"server_description"` + ServerName string `json:"server_name"` + ServerType string `json:"server_type"` + ServerURL string `json:"server_url"` + StreamStart string `json:"stream_start"` + Subtype string `json:"subtype,omitempty"` + Title string `json:"title"` + TotalMbytesSent int `json:"total_mbytes_sent"` + YpCurrentlyPlaying string `json:"yp_currently_playing"` + } `json:"source"` + } `json:"icestats"` +} + +func GetStats() (Info, error) { + now := time.Now() + if now.Before(latestInfo.Age.Add(time.Second * time.Duration(*bugTime))) { + return latestInfo.Info, nil + } + + i := Info{} + + resp, err := http.Get("http://dj.bronyradio.com:8000/status-json.xsl") + if err != nil { + return Info{}, err + } + + defer resp.Body.Close() + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + return Info{}, err + } + + err = json.Unmarshal(content, &i) + if err != nil { + return Info{}, err + } + + latestInfo.Info = i + latestInfo.Age = now + + return latestInfo.Info, nil +} |
