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  }