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 }