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  }