package engine import ( "context" "encoding/json" "errors" "fmt" "path/filepath" "sort" "strings" "github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/store" ) // Bulk actions apply one decision to many matches, typically whole albums. // Every match still goes through the same single-item code path (and its // preflight checks); bulk only adds planning, a preview and a job. const ( BulkLink = "link" BulkPromote = "promote" BulkIgnore = "ignore" BulkUnignore = "unignore" ) // ErrInvalidBulk marks a malformed bulk request (reported as a 400). var ErrInvalidBulk = errors.New("invalid bulk request") // MaxBulk caps one request so a mistaken "select all" stays reviewable. const MaxBulk = 20000 type BulkOptions struct { Action string `json:"action"` // AllowDowngrade includes matches where the kept copy is lower quality: // for link, the master is worse; for promote, the slave is worse. AllowDowngrade bool `json:"allow_downgrade"` KeepFolder bool `json:"keep_folder"` } // AlbumFolder is the folder a slave file is grouped under: its directory // with disc folders ("Disc 1", "CD2") folded into the album. func AlbumFolder(rel string) string { dir := filepath.Dir(rel) for dir != "." && discFolder.MatchString(filepath.Base(dir)) { dir = filepath.Dir(dir) } if dir == "." { return "" } return dir } func verdict(m store.Match) probe.Verdict { var v probe.Verdict _ = json.Unmarshal(m.Verdict, &v) return v } // planBulk decides whether a match takes part in a bulk action. It returns // the reason when it does not. func planBulk(pairID int64, m store.Match, opt BulkOptions) (bool, string) { if m.PairID != pairID { return false, "belongs to another library pair" } v := verdict(m) switch opt.Action { case BulkIgnore: if m.Ignored { return false, "already ignored" } return true, "" case BulkUnignore: if !m.Ignored { return false, "not ignored" } return true, "" } if m.Ignored { return false, "ignored" } switch m.Status { case store.StatusLinked: return false, "already linked" case store.StatusUnmatched: return false, "no master copy" case store.StatusError: return false, "has an error" } switch opt.Action { case BulkLink: if v.Better == "slave" && !opt.AllowDowngrade { return false, "master copy is lower quality" } return true, "" case BulkPromote: if m.Status == store.StatusIdentical { return false, "identical, so link instead" } if v.Better == "master" && !opt.AllowDowngrade { return false, "master copy is better" } return true, "" } return false, "unknown action" } // BulkPreview summarises a bulk action before it runs. type BulkPreview struct { Action string `json:"action"` Selected int `json:"selected"` Albums int `json:"albums"` Process int `json:"process"` Skip map[string]int `json:"skip"` // reason -> count // Among the matches that will be processed: Identical int `json:"identical"` // linked without quarantine Quarantined int `json:"quarantined"` // replaced file is kept in quarantine Upgrades int `json:"upgrades"` // the copy kept is better Downgrades int `json:"downgrades"` // the copy kept is worse (only with AllowDowngrade) Approximate int `json:"approximate"` // approximate matches MovesFolder int `json:"moves_folder"` // link moves the slave file into the master's folders Renames int `json:"renames"` // file name changes DurationMismatch int `json:"duration_mismatch"` // durations differ by more than 2 seconds BytesFreed int64 `json:"bytes_freed"` // identical slave files replaced by links // A few example paths per warning, so the user can spot-check. Examples map[string][]string `json:"examples"` } func (p *BulkPreview) example(kind, path string) { if len(p.Examples[kind]) < 3 { p.Examples[kind] = append(p.Examples[kind], path) } } func (e *Engine) PreviewBulk(ctx context.Context, pairID int64, ids []int64, opt BulkOptions) (BulkPreview, error) { ids = uniqueIDs(ids) p := BulkPreview{Action: opt.Action, Selected: len(ids), Skip: map[string]int{}, Examples: map[string][]string{}} if err := validBulk(ids, opt); err != nil { return p, err } ms, err := e.st.MatchesByIDs(ctx, ids) if err != nil { return p, err } albums := map[string]bool{} for _, m := range ms { albums[AlbumFolder(m.RelSlave)] = true ok, why := planBulk(pairID, m, opt) if !ok { p.Skip[why]++ continue } p.Process++ if opt.Action != BulkLink && opt.Action != BulkPromote { continue } v := verdict(m) kept := "master" if opt.Action == BulkPromote { kept = "slave" } switch { case v.Better == kept: p.Upgrades++ case v.Better != "" && v.Better != "equal" && v.Better != "unknown": p.Downgrades++ p.example("downgrades", m.RelSlave) } if m.Kind == store.KindApprox { p.Approximate++ p.example("approximate", m.RelSlave) } if v.DurationDelta > 2 { p.DurationMismatch++ p.example("duration_mismatch", m.RelSlave) } if filepath.Base(m.RelSlave) != filepath.Base(m.RelMaster) { p.Renames++ } if opt.Action == BulkLink { if m.Status == store.StatusIdentical { p.Identical++ p.BytesFreed += m.SlaveSize } else { p.Quarantined++ } if target := LinkTarget(m.RelSlave, m.RelMaster, opt.KeepFolder); filepath.Dir(target) != filepath.Dir(m.RelSlave) { p.MovesFolder++ p.example("moves_folder", m.RelSlave+" → "+target) } } else { p.Quarantined++ // every promoted master file is quarantined p.example("master_replaced", m.RelMaster) } } if missing := len(ids) - len(ms); missing > 0 { p.Skip["no longer exists (rescan)"] += missing } p.Albums = len(albums) return p, nil } // uniqueIDs drops duplicates (a song selected on its own and via its album). func uniqueIDs(ids []int64) []int64 { seen := make(map[int64]bool, len(ids)) out := make([]int64, 0, len(ids)) for _, id := range ids { if !seen[id] { seen[id] = true out = append(out, id) } } return out } func validBulk(ids []int64, opt BulkOptions) error { switch opt.Action { case BulkLink, BulkPromote, BulkIgnore, BulkUnignore: default: return fmt.Errorf("%w: unknown action %q", ErrInvalidBulk, opt.Action) } if len(ids) == 0 { return fmt.Errorf("%w: nothing selected", ErrInvalidBulk) } if len(ids) > MaxBulk { return fmt.Errorf("%w: select at most %d songs at a time", ErrInvalidBulk, MaxBulk) } return nil } // ApplyIgnore ignores or un-ignores matches immediately (no files change). func (e *Engine) ApplyIgnore(ctx context.Context, pairID int64, ids []int64, opt BulkOptions) (int, error) { ids = uniqueIDs(ids) if err := validBulk(ids, opt); err != nil { return 0, err } if opt.Action != BulkIgnore && opt.Action != BulkUnignore { return 0, fmt.Errorf("%w: not an ignore action", ErrInvalidBulk) } ms, err := e.st.MatchesByIDs(ctx, ids) if err != nil { return 0, err } n := 0 for _, m := range ms { if ok, _ := planBulk(pairID, m, opt); ok { if err := e.st.SetIgnored(ctx, m.ID, opt.Action == BulkIgnore); err != nil { return n, err } n++ } } return n, nil } // StartBulk links or promotes the selected matches in a background job. Each // match is re-planned when its turn comes (the scan may have changed it) and // then goes through the normal single-item operation. func (e *Engine) StartBulk(pairID int64, ids []int64, opt BulkOptions) (*Job, error) { if err := validBulk(ids, opt); err != nil { return nil, err } if opt.Action != BulkLink && opt.Action != BulkPromote { return nil, fmt.Errorf("%w: only link and promote run as a job", ErrInvalidBulk) } sorted := uniqueIDs(ids) sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) return e.startJob(pairID, "bulk-"+opt.Action, func(ctx context.Context, j *Job) error { verb := "Linking" if opt.Action == BulkPromote { verb = "Promoting slave copies" } j.phase(verb, int64(len(sorted))) for _, id := range sorted { if ctx.Err() != nil { return ctx.Err() } e.bulkOne(ctx, j, pairID, id, opt) j.tick() } return nil }) } func (e *Engine) bulkOne(ctx context.Context, j *Job, pairID, id int64, opt BulkOptions) { m, err := e.st.Match(ctx, id) if err != nil { j.skip(fmt.Sprintf("match %d: no longer exists (rescan)", id)) return } if ok, why := planBulk(pairID, m, opt); !ok { j.skip(m.RelSlave + ": " + why) return } var op store.Operation if opt.Action == BulkLink { op, err = e.Link(ctx, id, LinkOptions{AllowDowngrade: opt.AllowDowngrade, KeepFolder: opt.KeepFolder, Trigger: "bulk"}) } else { op, err = e.promote(ctx, id, "bulk") } switch { case err != nil: j.skip(m.RelSlave + ": " + strings.TrimSpace(err.Error())) case op.DryRun: j.count("planned") case opt.Action == BulkLink: j.count("linked") default: j.count("promoted") } } // AlbumGroup is one album folder of the slave library in the Review list. type AlbumGroup struct { Folder string `json:"folder"` IDs []int64 `json:"ids"` Songs int `json:"songs"` Identical int `json:"identical"` Review int `json:"review"` // different + similar Upgrades int `json:"upgrades"` Downgrades int `json:"downgrades"` Approximate int `json:"approximate"` Other int `json:"other"` // linked, unmatched, error, ignored Bytes int64 `json:"bytes"` // MasterFolders lists where the matched master files live (usually one). MasterFolders []string `json:"master_folders"` } // Groups returns the matches of a filter grouped by album folder. func (e *Engine) Groups(ctx context.Context, pairID int64, f store.MatchFilter) ([]AlbumGroup, error) { byFolder := map[string]*AlbumGroup{} masters := map[string]map[string]bool{} err := e.st.ExportMatches(ctx, pairID, f, func(m store.Match) error { folder := AlbumFolder(m.RelSlave) g := byFolder[folder] if g == nil { g = &AlbumGroup{Folder: folder, IDs: []int64{}} byFolder[folder] = g masters[folder] = map[string]bool{} } g.IDs = append(g.IDs, m.ID) g.Songs++ g.Bytes += m.SlaveSize switch { case m.Ignored || m.Status == store.StatusLinked || m.Status == store.StatusUnmatched || m.Status == store.StatusError: g.Other++ case m.Status == store.StatusIdentical: g.Identical++ default: g.Review++ switch verdict(m).Better { case "master": g.Upgrades++ case "slave": g.Downgrades++ } } if m.Kind == store.KindApprox { g.Approximate++ } if m.RelMaster != "" { masters[folder][AlbumFolder(m.RelMaster)] = true } return nil }) if err != nil { return nil, err } out := make([]AlbumGroup, 0, len(byFolder)) for folder, g := range byFolder { for mf := range masters[folder] { g.MasterFolders = append(g.MasterFolders, mf) } sort.Strings(g.MasterFolders) if g.MasterFolders == nil { g.MasterFolders = []string{} } out = append(out, *g) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Folder) < strings.ToLower(out[j].Folder) }) return out, nil }