github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/update/server.go (about)

     1  // Copyright 2020 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package update
     5  
     6  import (
     7  	"bytes"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"io/ioutil"
    12  	"log"
    13  	"net/http"
    14  	"net/url"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/derat/nup/cmd/nup/client"
    19  	"github.com/derat/nup/server/db"
    20  )
    21  
    22  const (
    23  	tlsTimeout       = time.Minute
    24  	importBatchSize  = 50 // max songs to import per HTTP request
    25  	importTries      = 3
    26  	importRetryDelay = 3 * time.Second
    27  )
    28  
    29  // I started seeing "net/http: TLS handshake timeout" errors when trying to import songs.
    30  // I'm not sure if this is just App Engine flakiness or something else, but I didn't see
    31  // the error again after increasing the timeout.
    32  var httpClient = &http.Client{
    33  	Transport: &http.Transport{TLSHandshakeTimeout: tlsTimeout},
    34  }
    35  
    36  // sendRequest sends the specified request to the server and returns the response body.
    37  // r contains the request body and may be nil.
    38  // ctype contains the value for the Content-Type header if non-empty.
    39  func sendRequest(cfg *client.Config, method, path, query string,
    40  	r io.Reader, ctype string) ([]byte, error) {
    41  	u := cfg.GetURL(path)
    42  	if query != "" {
    43  		u.RawQuery = query
    44  	}
    45  	req, err := http.NewRequest(method, u.String(), r)
    46  	if err != nil {
    47  		return nil, err
    48  	}
    49  	req.SetBasicAuth(cfg.Username, cfg.Password)
    50  	if ctype != "" {
    51  		req.Header.Set("Content-Type", ctype)
    52  	}
    53  
    54  	resp, err := httpClient.Do(req)
    55  	if err != nil {
    56  		return nil, err
    57  	}
    58  	defer resp.Body.Close()
    59  	b, err := ioutil.ReadAll(resp.Body)
    60  	if err != nil {
    61  		return b, err
    62  	}
    63  	if resp.StatusCode != http.StatusOK {
    64  		return b, fmt.Errorf("got status %q", resp.Status)
    65  	}
    66  	return b, nil
    67  }
    68  
    69  // importSongsFlag values can be masked together to configure importSongs's behavior.
    70  type importSongsFlag uint32
    71  
    72  const (
    73  	// importReplaceUserData indicates that user data (e.g. rating, tags, plays) should be
    74  	// replaced with data from ch; otherwise the existing data is preserved and only static
    75  	// fields (e.g. artist, title, album, etc.) are replaced.
    76  	importReplaceUserData importSongsFlag = 1 << iota
    77  	// importUseFilenames indicates that the server should identify songs to import by their
    78  	// filenames rather than by SHA1s of their audio data. This can be used to avoid creating
    79  	// a new database object after deliberately modifying a song file's audio data.
    80  	importUseFilenames
    81  	// importNoRetryDelay indicates that importSongs should not sleep after a failed HTTP
    82  	// request. This is just useful for unit tests.
    83  	importNoRetryDelay
    84  )
    85  
    86  // importSongs reads all songs from ch and sends them to the server.
    87  func importSongs(cfg *client.Config, ch chan db.Song, flags importSongsFlag) error {
    88  	var args []string
    89  	if flags&importReplaceUserData != 0 {
    90  		args = append(args, "replaceUserData=1")
    91  	}
    92  	if flags&importUseFilenames != 0 {
    93  		args = append(args, "useFilenames=1")
    94  	}
    95  	query := strings.Join(args, "&")
    96  
    97  	sendFunc := func(body []byte) error {
    98  		var err error
    99  		for try := 1; try <= importTries; try++ {
   100  			r := bytes.NewReader(body)
   101  			if _, err = sendRequest(cfg, "POST", "/import", query, r, "text/plain"); err == nil {
   102  				break
   103  			} else if try < importTries {
   104  				delay := importRetryDelay
   105  				if flags&importNoRetryDelay != 0 {
   106  					delay = 0
   107  				}
   108  				log.Printf("Sleeping %v before retrying after error: %v", delay, err)
   109  				time.Sleep(delay)
   110  			}
   111  		}
   112  		return err
   113  	}
   114  
   115  	// Ideally these results could just be streamed, but dev_appserver.py doesn't seem to support
   116  	// chunked encoding: https://code.google.com/p/googleappengine/issues/detail?id=129
   117  	// Might be for the best, as the max request duration could probably be hit otherwise.
   118  	var numSongs int
   119  	var buf bytes.Buffer
   120  	e := json.NewEncoder(&buf)
   121  	for s := range ch {
   122  		numSongs++
   123  		if err := e.Encode(s); err != nil {
   124  			return fmt.Errorf("failed to encode song: %v", err)
   125  		}
   126  		if numSongs%importBatchSize == 0 {
   127  			// Pass the underlying bytes rather than an io.Reader so sendFunc() can re-read the
   128  			// data if it needs to retry due to network issues or App Engine flakiness.
   129  			if err := sendFunc(buf.Bytes()); err != nil {
   130  				return err
   131  			}
   132  			buf.Reset()
   133  		}
   134  	}
   135  	if buf.Len() > 0 {
   136  		if err := sendFunc(buf.Bytes()); err != nil {
   137  			return err
   138  		}
   139  	}
   140  	return nil
   141  }
   142  
   143  // dumpSong dumps the song with the specified ID from the server.
   144  // User data like ratings, tags, and plays are included.
   145  func dumpSong(cfg *client.Config, songID int64) (db.Song, error) {
   146  	b, err := sendRequest(cfg, "GET", "/dump_song", fmt.Sprintf("songId=%v", songID), nil, "")
   147  	if err != nil {
   148  		return db.Song{}, err
   149  	}
   150  	var s db.Song
   151  	err = json.Unmarshal(b, &s)
   152  	return s, err
   153  }
   154  
   155  // deleteSong deletes the song with the specified ID from the server.
   156  func deleteSong(cfg *client.Config, songID int64) error {
   157  	params := fmt.Sprintf("songId=%v", songID)
   158  	_, err := sendRequest(cfg, "POST", "/delete_song", params, nil, "text/plain")
   159  	return err
   160  }
   161  
   162  // reindexSongs asks the server to reindex all songs' search data.
   163  func reindexSongs(cfg *client.Config) error {
   164  	var cursor string
   165  	var scanned, updated int // totals
   166  	for {
   167  		var res struct {
   168  			Scanned int    `json:"scanned"`
   169  			Updated int    `json:"updated"`
   170  			Cursor  string `json:"cursor"`
   171  		}
   172  		query := "cursor=" + url.QueryEscape(cursor)
   173  		if b, err := sendRequest(cfg, "POST", "/reindex", query, nil, ""); err != nil {
   174  			return err
   175  		} else if err := json.Unmarshal(b, &res); err != nil {
   176  			return err
   177  		}
   178  		scanned += res.Scanned
   179  		updated += res.Updated
   180  		log.Printf("Scanned %v songs, updated %v", scanned, updated)
   181  		if cursor = res.Cursor; cursor == "" {
   182  			return nil
   183  		}
   184  	}
   185  }