package download import ( "cmp" "context" "fmt" "strings" "yellowjacket/backend/jobs" ) // Filling in an almost-complete album (#276). // // A grab that delivers nine of twelve tracks clears the completeness // floor and is imported, and before this the other three were never // looked for. On Soulseek that is the commonest way an album ends up // almost right: one peer's folder is missing a track, or one file // failed. So after an import, each missing track is searched for on // its own and fetched from somewhere else, into the same album. // maxFillInTracks bounds how many tracks are fetched one by one. An // album missing more than a few is a different candidate's job, not a // dozen single-track grabs. const maxFillInTracks = 3 // fillIn fetches the tracks a successful import did not deliver. It // never fails the download: the album is already imported, and a track // it cannot find is logged and left. func (m *Manager) fillIn( ctx context.Context, dl Download, main Candidate, imported ImportResult, job *jobs.Handle, ) { missing := missingTracks(dl, imported.Matched) if len(missing) == 0 { return } for _, t := range missing { if ctx.Err() != nil { return } paths, err := m.fillInTrack(ctx, dl, main, t, job) if err != nil { m.logger.Info( "could not fill in a missing track", "download", dl.ID, "track", t.Title, "error", err, ) if job != nil { job.Logf(jobs.LevelWarn, fmt.Sprintf( "Could not find %q elsewhere: %v", t.Title, err, )) } continue } if job != nil { job.Logf(jobs.LevelInfo, fmt.Sprintf( "Filled in %q from another source (%d file)", t.Title, len(paths), )) } } } // missingTracks is what an import left out, when filling it in is // worth trying: an album with a tracklist, a few tracks short. func missingTracks(dl Download, matched []ExpectedTrack) []ExpectedTrack { // A recording request is one track; there is no album to complete. if dl.RecordingMBID != "" || len(dl.Expected) < 2 { return nil } have := make(map[trackKey]bool, len(matched)) for _, t := range matched { have[keyOf(t)] = true } var missing []ExpectedTrack for _, t := range dl.Expected { if !have[keyOf(t)] { missing = append(missing, t) } } // A half-empty album was a poor copy, not a nearly complete one. if len(missing) > maxFillInTracks || 2*len(missing) >= len(dl.Expected) { return nil } return missing } // fillInTrack searches for one track and grabs the first acceptable // copy that is not from the source that already failed to supply it. // One attempt: a fill-in that walks a candidate list per track would // multiply a download's grabs by the number of gaps. func (m *Manager) fillInTrack( ctx context.Context, dl Download, main Candidate, t ExpectedTrack, job *jobs.Handle, ) ([]string, error) { want := trackRequest(dl, t) ranked, err := m.Search(ctx, want) if err != nil { return nil, err } prefs := m.preferences() for _, c := range ranked { if ruledOutBy(c, []Candidate{main}) || !autoAcceptable(want, c, prefs) { continue } // The track is being put into an album this app placed; a // delegate would put it in its own library instead. if plan, err := m.planTransfer(want, c); err != nil || plan.delegated() { continue } narrowed, ok := narrowTo(c, t) if !ok { continue } out := m.attemptGrab(ctx, dl, narrowed, job, []ExpectedTrack{t}) if out.item.StagingDir != "" { if err := m.staging.Release(out.item.StagingDir); err != nil { m.logger.Warn("could not release staging dir", "error", err) } } if out.err != nil { return nil, out.err } if err := m.store.SetItemImported( ctx, out.item.ID, out.imported.Paths, ); err != nil { m.logger.Warn("could not record imported paths", "error", err) } return out.imported.Paths, nil } return nil, ErrNoCandidates } // trackRequest is the search for one track of an album: the track's // artist and title as the query, the album kept so a copy from that // album outranks the same song off a compilation, and one expected // track so a single file is a complete answer. func trackRequest(dl Download, t ExpectedTrack) Download { artist := cmp.Or(t.Artist, dl.Artist) want := dl want.Query = strings.TrimSpace(artist + " " + t.Title) want.Expected = []ExpectedTrack{t} return want } // narrowTo trims a candidate to the one file that aligns to t, so the // grab fetches a track rather than whatever else the folder offered. func narrowTo(c Candidate, t ExpectedTrack) (Candidate, bool) { aligned, _ := matchFiles(c.Files, []ExpectedTrack{t}) for _, f := range aligned { if f.IsAudio && f.MatchedTo == t.Position { c.Files = []CandidateFile{f} c.TotalSize = f.Size return c, true } } return Candidate{}, false }