github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/server/song.go (about) 1 // Copyright 2021 Daniel Erat. 2 // All rights reserved. 3 4 package main 5 6 import ( 7 "bytes" 8 "context" 9 "errors" 10 "fmt" 11 "io" 12 "net/http" 13 "os" 14 "path/filepath" 15 "regexp" 16 "strconv" 17 "sync" 18 "time" 19 20 "github.com/derat/nup/server/config" 21 "github.com/derat/nup/server/storage" 22 23 "google.golang.org/appengine/v2/log" 24 ) 25 26 const ( 27 // openSong saves song data in-memory if it's this many bytes or smaller. 28 // Per https://cloud.google.com/appengine/docs/standard, F1 second-gen 29 // runtimes have a 256 MB memory limit. 30 maxSongMemSize = 32 * 1024 * 1024 31 32 // Maximum range request size to service in a single response. 33 // Per https://cloud.google.com/appengine/docs/standard/go/how-requests-are-handled, 34 // App Engine permits 32 MB responses, but we need to reserve a bit of extra space 35 // to make sure we don't go over the limit with headers. 36 maxFileRangeSize = 32*1024*1024 - 32*1024 37 ) 38 39 var ( 40 // These correspond to the Cloud Storage object that was last accessed via openSong. 41 // Chrome can send multiple requests for a single file, so holding song data in memory lets us 42 // avoid reading the same bytes from GCS multiple times. Returning stale objects hopefully isn't 43 // a concern, since clients will probably already have a bad time if a song changes while 44 // they're in the process of playing it. 45 lastSongName string // name of object in lastSongData 46 lastSongData []byte // contents of last object from openSong 47 lastSongModTime time.Time // object's last-modified time 48 lastSongMutex sync.Mutex // guards other lastSong variables 49 ) 50 51 // getSongData atomically returns lastSongData and lastSongModTime if lastSongName matches name. 52 // nil and a zero time are returned if the names don't match. 53 func getSongData(name string) ([]byte, time.Time) { 54 lastSongMutex.Lock() 55 defer lastSongMutex.Unlock() 56 if name == lastSongName { 57 return lastSongData, lastSongModTime 58 } 59 return nil, time.Time{} 60 } 61 62 // setSongData atomically updates lastSongName, lastSongData, and lastSongModTime. 63 func setSongData(name string, data []byte, lastMod time.Time) { 64 lastSongMutex.Lock() 65 lastSongName = name 66 lastSongData = data 67 lastSongModTime = lastMod 68 lastSongMutex.Unlock() 69 } 70 71 // songReader implements io.ReadSeekCloser along with methods needed by sendSong. 72 type songReader interface { 73 Read(b []byte) (n int, err error) 74 Seek(offset int64, whence int) (int64, error) 75 Close() error 76 Name() string 77 LastMod() time.Time 78 Size() int64 79 } 80 81 // byteSongReader implements songReader for a byte slice. 82 type bytesSongReader struct { 83 r *bytes.Reader 84 name string 85 lastMod time.Time 86 } 87 88 func newBytesSongReader(b []byte, name string, lastMod time.Time) *bytesSongReader { 89 return &bytesSongReader{bytes.NewReader(b), name, lastMod} 90 } 91 func (br *bytesSongReader) Read(b []byte) (int, error) { return br.r.Read(b) } 92 func (br *bytesSongReader) Seek(offset int64, whence int) (int64, error) { 93 return br.r.Seek(offset, whence) 94 } 95 func (br *bytesSongReader) Close() error { return nil } 96 func (br *bytesSongReader) Name() string { return br.name } 97 func (br *bytesSongReader) LastMod() time.Time { return br.lastMod } 98 func (br *bytesSongReader) Size() int64 { return br.r.Size() } 99 100 var _ songReader = (*bytesSongReader)(nil) // verify that interface is implemented 101 102 // openSong opens the song at fn (using either Cloud Storage or HTTP). 103 // The returned reader will also implement songReader when reading from Cloud Storage 104 // or serving an in-memory song that was previously read from Cloud Storage. 105 // os.ErrNotExist is returned if the file is not present. 106 func openSong(ctx context.Context, cfg *config.Config, fn string) (io.ReadCloser, error) { 107 switch { 108 case cfg.SongBucket != "": 109 // If we already have the song in memory, return it. 110 if b, t := getSongData(fn); b != nil { 111 log.Debugf(ctx, "Using in-memory copy of %q", fn) 112 return newBytesSongReader(b, fn, t), nil 113 } 114 or, err := storage.NewObjectReader(ctx, cfg.SongBucket, fn) 115 if err != nil { 116 return nil, err 117 } else if or.Size() > maxSongMemSize { 118 return or, nil // too big to load into memory 119 } 120 log.Debugf(ctx, "Reading %q into memory", fn) 121 defer or.Close() 122 setSongData("", nil, time.Time{}) // clear old buffer 123 b := make([]byte, or.Size()) 124 if _, err := io.ReadFull(or, b); err != nil { 125 return nil, err 126 } 127 setSongData(fn, b, or.LastMod()) 128 return newBytesSongReader(b, fn, or.LastMod()), nil 129 case cfg.SongBaseURL != "": 130 u := cfg.SongBaseURL + fn 131 log.Debugf(ctx, "Opening %v", u) 132 if resp, err := http.Get(u); err != nil { 133 return nil, err 134 } else if resp.StatusCode >= 300 { 135 resp.Body.Close() 136 if resp.StatusCode == 404 { 137 return nil, os.ErrNotExist 138 } 139 return nil, fmt.Errorf("server replied with %q", resp.Status) 140 } else { 141 return resp.Body, nil 142 } 143 default: 144 return nil, errors.New("neither SongBucket nor SongBaseURL is set") 145 } 146 } 147 148 // sendSong copies data from r to w, handling range requests and setting any necessary headers. 149 // If the request can't be satisfied, writes an HTTP error to w. 150 func sendSong(ctx context.Context, req *http.Request, w http.ResponseWriter, r songReader) error { 151 // If the file fits within App Engine's limit, just use http.ServeContent, 152 // which handles range requests and last-modified/conditional stuff. 153 size := r.Size() 154 if size <= maxFileRangeSize { 155 var rng string 156 if v := req.Header.Get("Range"); v != "" { 157 rng = " (" + v + ")" 158 } 159 log.Debugf(ctx, "Sending file of size %d%v", size, rng) 160 http.ServeContent(w, req, filepath.Base(r.Name()), r.LastMod(), r) 161 return nil 162 } 163 164 // App Engine only permits responses up to 32 MB, so always send a partial response if the 165 // requested range exceeds that: https://github.com/derat/nup/issues/10 166 167 // TODO: I didn't see this called out in a skimming of https://tools.ietf.org/html/rfc7233, but 168 // it's almost certainly bogus to send a 206 for a non-range request. I don't know what else we 169 // can do, though. This implementation is also pretty broken (e.g. it doesn't look at 170 // precondition fields). 171 172 // Parse the Range header if one was supplied. 173 rng := req.Header.Get("Range") 174 start, end, ok := parseRangeHeader(rng) 175 if !ok || start >= size { 176 // Fall back to using ServeContent for non-trivial requests. We may hit the 32 MB limit here. 177 log.Debugf(ctx, "Unable to handle range %q for file of size %d", rng, size) 178 http.ServeContent(w, req, filepath.Base(r.Name()), r.LastMod(), r) 179 return nil 180 } 181 182 // Rewrite open-ended requests. 183 if end == -1 { 184 end = size - 1 185 } 186 // If the requested range is too large, limit it to the max response size. 187 if end-start+1 > maxFileRangeSize { 188 end = start + maxFileRangeSize - 1 189 } 190 log.Debugf(ctx, "Sending bytes %d-%d/%d for requested range %q", start, end, size, rng) 191 192 if _, err := r.Seek(start, 0); err != nil { 193 http.Error(w, err.Error(), http.StatusInternalServerError) 194 return err 195 } 196 197 w.Header().Set("Accept-Ranges", "bytes") 198 w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) 199 w.Header().Set("Content-Type", "audio/mpeg") 200 w.Header().Set("Last-Modified", r.LastMod().UTC().Format(time.RFC1123)) 201 w.WriteHeader(http.StatusPartialContent) 202 203 _, err := io.CopyN(w, r, end-start+1) 204 return err 205 } 206 207 var rangeRegexp = regexp.MustCompile(`^bytes=(\d+)-(\d+)?$`) 208 209 // parseRangeHeader parses an HTTP request Range header in the form "bytes=123-" or 210 // "bytes=123-456" and returns the inclusive start and ending offsets. The ending 211 // offset is -1 if it wasn't specified in the header, indicating the end of the file. 212 // Returns false if the header was empty, invalid, or doesn't match the above forms. 213 func parseRangeHeader(head string) (start, end int64, ok bool) { 214 // If the header wasn't specified, they want the whole file. 215 if head == "" { 216 return 0, -1, true 217 } 218 219 // If the range is more complicated than a start with an optional end, give up. 220 ms := rangeRegexp.FindStringSubmatch(head) 221 if ms == nil { 222 return 0, 0, false 223 } 224 225 // Otherwise, extract the offsets. 226 start, err := strconv.ParseInt(ms[1], 10, 64) 227 if start < 0 || err != nil { 228 return 0, 0, false 229 } 230 if ms[2] == "" { 231 end = -1 // end of file 232 } else if end, err = strconv.ParseInt(ms[2], 10, 64); err != nil || end < start { 233 return 0, 0, false 234 } 235 return start, end, true 236 }