github.com/antonyho/crhk-recorder@v0.0.0-20220803065738-4962dcafa06f/pkg/stream/recorder/recorder.go (about) 1 package recorder 2 3 import ( 4 "bufio" 5 "errors" 6 "fmt" 7 "io" 8 "io/ioutil" 9 "log" 10 "net/http" 11 "os" 12 "path/filepath" 13 "time" 14 15 dow "github.com/antonyho/crhk-recorder/pkg/dayofweek" 16 "github.com/antonyho/crhk-recorder/pkg/stream/resolver" 17 "github.com/antonyho/crhk-recorder/pkg/stream/url" 18 ) 19 20 const ( 21 // ConsecutiveErrorTolerance is the number of failures on downloading a stream 22 // which shall be tolerated 23 ConsecutiveErrorTolerance = 15 24 25 // OneDay time value 26 OneDay = 24 * time.Hour 27 28 // TwoSeconds time value 29 TwoSeconds = 2 * time.Second 30 ) 31 32 // Recorder CRHK radio channel broadcasted online 33 type Recorder struct { 34 Channel string 35 ChannelName string // specifies with stream sound quality (e.g. 881HD) 36 StreamServer string 37 cloudfrontSessionCookie *resolver.CloudfrontCookie 38 downloaded map[string]bool 39 } 40 41 // NewRecorder is a constructor for Recorder 42 func NewRecorder(channel string) *Recorder { 43 return &Recorder{ 44 Channel: channel, 45 downloaded: make(map[string]bool), 46 } 47 } 48 49 func (r *Recorder) clearStreamSource() { 50 r.ChannelName = "" 51 r.StreamServer = "" 52 r.cloudfrontSessionCookie = nil 53 } 54 55 func (r *Recorder) cleanup() { 56 r.downloaded = make(map[string]bool) 57 r.clearStreamSource() 58 } 59 60 // Download the media from channel playlist 61 func (r *Recorder) Download(targetFile io.Writer) error { 62 if r.ChannelName == "" || 63 r.StreamServer == "" || 64 r.cloudfrontSessionCookie == nil || 65 !r.cloudfrontSessionCookie.Assigned() { 66 channelName, streamServer, cloudfrontCookie, err := resolver.Find(r.Channel) 67 if err != nil { 68 return err 69 } 70 r.ChannelName = channelName 71 r.StreamServer = streamServer 72 r.cloudfrontSessionCookie = &cloudfrontCookie 73 } 74 75 playlist, err := resolver.GetPlaylist(r.ChannelName, r.StreamServer, *r.cloudfrontSessionCookie) 76 if err != nil { 77 return err 78 } 79 80 var lastTrackDuration time.Duration 81 playlistDownloadStartTime := time.Now() 82 for _, track := range playlist { 83 if downloaded, found := r.downloaded[track.Path]; found && downloaded { 84 // Skip if the same track has been downloaded 85 continue 86 } 87 88 // Add CloudFront headers to the request 89 req, err := http.NewRequest(http.MethodGet, url.StreamMediaURL(r.ChannelName, r.StreamServer, track.Path), nil) 90 if err != nil { 91 return err 92 } 93 req.AddCookie(&http.Cookie{Name: resolver.CloudFrontCookieNamePolicy, Value: r.cloudfrontSessionCookie.Policy}) 94 req.AddCookie(&http.Cookie{Name: resolver.CloudFrontCookieNameKeyPairID, Value: r.cloudfrontSessionCookie.KeyPairID}) 95 req.AddCookie(&http.Cookie{Name: resolver.CloudFrontCookieNameSignature, Value: r.cloudfrontSessionCookie.Signature}) 96 97 c := &http.Client{} 98 resp, err := c.Do(req) 99 if err != nil { 100 return err 101 } 102 if resp.StatusCode != http.StatusOK { 103 return fmt.Errorf("media file: unsuccessful HTTP request. response code: %d", resp.StatusCode) 104 } 105 media, err := ioutil.ReadAll(resp.Body) 106 if err != nil { 107 return err 108 } 109 resp.Body.Close() 110 contentSize := len(media) 111 if contentSize == 0 { 112 return errors.New("empty media file") 113 } 114 115 written, err := targetFile.Write(media) 116 if err != nil { 117 return err 118 } else if written != contentSize { 119 return fmt.Errorf("written byte size %d does not match with file size %d", written, contentSize) 120 } 121 r.downloaded[track.Path] = true 122 lastTrackDuration = time.Duration(track.Time) * time.Second 123 } 124 125 if time.Since(playlistDownloadStartTime) < lastTrackDuration { 126 time.Sleep(lastTrackDuration - TwoSeconds) // Wait 2 seconds less to be secure 127 } 128 129 return nil 130 } 131 132 // Record the given channel 133 func (r *Recorder) Record(startFrom, until time.Time) error { 134 if startFrom.After(until) { 135 panic("incorrect time sequence") 136 } 137 138 currExecDirPath, err := os.Getwd() 139 if err != nil { 140 return err 141 } 142 mediaFilename := fmt.Sprintf("%s-%s.aac", r.Channel, startFrom.Format("2006-01-02-150405")) 143 fileDestPath := filepath.Join(currExecDirPath, mediaFilename) 144 f, err := os.Create(fileDestPath) 145 if err != nil { 146 return err 147 } 148 defer f.Close() 149 bufFile := bufio.NewWriter(f) 150 defer bufFile.Flush() 151 152 diffFromStartTime := time.Until(startFrom) 153 diffFromEndTime := time.Until(until) 154 155 termination := make(chan bool) 156 go func() { 157 <-time.After(diffFromEndTime) 158 termination <- true 159 }() 160 161 failCount := 0 162 163 <-time.After(diffFromStartTime) 164 for { 165 select { 166 case <-termination: 167 r.cleanup() 168 return nil 169 170 default: 171 if err := r.Download(bufFile); err != nil { 172 if failCount < ConsecutiveErrorTolerance { 173 log.Printf("Download Error: %+v", err) 174 r.clearStreamSource() // Probably stream source was wrong 175 time.Sleep(calculateRetryDelay(failCount)) 176 failCount++ 177 continue 178 } else { 179 return err 180 } 181 } 182 if err := bufFile.Flush(); err != nil { 183 return err 184 } 185 failCount = 0 186 } 187 } 188 } 189 190 // Schedule a time to start and end recording everyday 191 // wd is a flag mask to control which day of week should be recorded 192 // endless controls if the schedule would continue endlessly on next scheduled day 193 // startTime format: 13:23:45 +0100 (24H with timezone offset) 194 func (r *Recorder) Schedule(startTime, endTime string, wd dow.Bitmask, endless bool) error { 195 var timeDelay time.Duration 196 thisYear, thisMonth, thisDay := time.Now().Date() 197 start, err := time.Parse("15:04:05 -0700", startTime) 198 if err != nil { 199 return err 200 } 201 start = start.AddDate(thisYear, int(thisMonth)-1, thisDay-1) 202 end, err := time.Parse("15:04:05 -0700", endTime) 203 if err != nil { 204 return err 205 } 206 end = end.AddDate(thisYear, int(thisMonth)-1, thisDay-1) 207 if end.Before(start) { // To cover an overnight recording 208 end = end.Add(OneDay) 209 } 210 for !wd.AllEnabled() && !wd.Enabled(start.Weekday()) { 211 start = start.Add(OneDay) 212 end = end.Add(OneDay) 213 } 214 if start.Before(time.Now()) { // To cover start time already passed 215 timeDelay = OneDay 216 start = start.Add(timeDelay) 217 end = end.Add(timeDelay) 218 } 219 220 for { 221 log.Printf("The next recording schedule: %s - %s", start.Format("2006-01-02 15:04:05 -0700"), end.Format("2006-01-02 15:04:05 -0700")) 222 if time.Until(start) > time.Minute { 223 // Wait a bit if the start time to more than 1 minute apart 224 <-time.After(time.Until(start.Add(-10 * time.Second))) 225 } 226 if err := r.Record(start, end); err != nil { 227 return err 228 } 229 if endless { 230 start = start.Add(OneDay) 231 end = end.Add(OneDay) 232 for !wd.AllEnabled() && !wd.Enabled(start.Weekday()) { 233 start = start.Add(OneDay) 234 end = end.Add(OneDay) 235 } 236 } else { 237 break 238 } 239 } 240 241 return nil 242 } 243 244 func calculateRetryDelay(count int) time.Duration { 245 if count > 0 { 246 return time.Duration(count) * time.Second 247 } 248 return 0 249 }