DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
@@ -363,10 +363,11 @@ func run() error {
|
|||||||
|
|
||||||
// Duplicate resolver (M498, M502): classifies the sweep's groups and, with
|
// Duplicate resolver (M498, M502): classifies the sweep's groups and, with
|
||||||
// the operator's auto-resolve on, merges the copies that are safe to merge.
|
// the operator's auto-resolve on, merges the copies that are safe to merge.
|
||||||
// Started after SetNotifier so its summaries reach admins. See
|
// Started after SetNotifier so its summaries reach admins. It asks the
|
||||||
// internal/library/duplicate_resolve.go.
|
// catalogue for the track list of an album whose wrong slot it must
|
||||||
|
// decide, a few a pass. See internal/library/duplicate_resolve.go.
|
||||||
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
|
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
|
||||||
cfg.Storage.DataDir, fpSettings).Run(ctx)
|
cfg.Storage.DataDir, fpSettings, cat.RefreshReleaseGroup).Run(ctx)
|
||||||
|
|
||||||
// Missing-file re-acquisition (milestone #290, M502 #5520). Wants back
|
// Missing-file re-acquisition (milestone #290, M502 #5520). Wants back
|
||||||
// albums whose files have been gone longer than the grace window; the
|
// albums whose files have been gone longer than the grace window; the
|
||||||
|
|||||||
@@ -112,10 +112,24 @@ func mergePlan(g *resolveGroup) (ok bool, survivor int) {
|
|||||||
return false, -1
|
return false, -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrackListFetcher reads a release group's releases and track lists from
|
||||||
|
// MusicBrainz into the catalogue (catalogue.Service.RefreshReleaseGroup).
|
||||||
|
type TrackListFetcher func(ctx context.Context, releaseGroupMBID string) error
|
||||||
|
|
||||||
// ResolveDuplicates runs one pass. act is the operator's auto-resolve setting;
|
// ResolveDuplicates runs one pass. act is the operator's auto-resolve setting;
|
||||||
// with it off, the pass only classifies.
|
// with it off, the pass only classifies. It reads only track lists already in
|
||||||
|
// the catalogue; ResolveDuplicatesFetching may also fetch missing ones.
|
||||||
func ResolveDuplicates(
|
func ResolveDuplicates(
|
||||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, act bool,
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, act bool,
|
||||||
|
) (DuplicateResolveResult, error) {
|
||||||
|
return ResolveDuplicatesFetching(ctx, pool, logger, dataDir, act, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDuplicatesFetching is ResolveDuplicates that asks MusicBrainz, through
|
||||||
|
// fetch, for the track list of an album whose wrong slot it must decide and
|
||||||
|
// the catalogue has never read: a few per pass (trackListFetchesPerPass).
|
||||||
|
func ResolveDuplicatesFetching(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, act bool, fetch TrackListFetcher,
|
||||||
) (DuplicateResolveResult, error) {
|
) (DuplicateResolveResult, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
@@ -182,7 +196,7 @@ func ResolveDuplicates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.Dismissed = dismissDistinct(ctx, pool, logger, groups, merged)
|
res.Dismissed = dismissDistinct(ctx, pool, logger, groups, merged)
|
||||||
wrong, names := settleWrongSlots(ctx, pool, logger, groups, merged)
|
wrong, names := settleWrongSlots(ctx, pool, logger, groups, merged, fetch)
|
||||||
res.WrongFiles = wrong
|
res.WrongFiles = wrong
|
||||||
mergedNames = append(mergedNames, names...)
|
mergedNames = append(mergedNames, names...)
|
||||||
|
|
||||||
@@ -375,14 +389,18 @@ type DuplicateResolveWorker struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
dataDir string
|
dataDir string
|
||||||
settings *FingerprintSettingsService
|
settings *FingerprintSettingsService
|
||||||
|
fetch TrackListFetcher
|
||||||
tick time.Duration
|
tick time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDuplicateResolveWorker builds a worker with the production cadence.
|
// NewDuplicateResolveWorker builds a worker with the production cadence. fetch
|
||||||
|
// reads a missing track list from MusicBrainz; nil reads only what is held.
|
||||||
func NewDuplicateResolveWorker(
|
func NewDuplicateResolveWorker(
|
||||||
pool *pgxpool.Pool, logger *slog.Logger, dataDir string, settings *FingerprintSettingsService,
|
pool *pgxpool.Pool, logger *slog.Logger, dataDir string, settings *FingerprintSettingsService, fetch TrackListFetcher,
|
||||||
) *DuplicateResolveWorker {
|
) *DuplicateResolveWorker {
|
||||||
return &DuplicateResolveWorker{pool: pool, logger: logger, dataDir: dataDir, settings: settings, tick: duplicateResolveTick}
|
return &DuplicateResolveWorker{
|
||||||
|
pool: pool, logger: logger, dataDir: dataDir, settings: settings, fetch: fetch, tick: duplicateResolveTick,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run blocks until ctx is cancelled, running a pass at start and then each tick.
|
// Run blocks until ctx is cancelled, running a pass at start and then each tick.
|
||||||
@@ -418,7 +436,7 @@ func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res, err := ResolveDuplicates(ctx, w.pool, w.logger, w.dataDir, w.settings.Get().AutoResolve)
|
res, err := ResolveDuplicatesFetching(ctx, w.pool, w.logger, w.dataDir, w.settings.Get().AutoResolve, w.fetch)
|
||||||
if err != nil && ctx.Err() == nil {
|
if err != nil && ctx.Err() == nil {
|
||||||
w.logger.Warn("duplicate resolve: pass failed", "err", err)
|
w.logger.Warn("duplicate resolve: pass failed", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,6 +405,33 @@ func TestResolveDuplicates_RemovesACopyFromAnotherSongsSlot_Integration(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An album whose track list the catalogue never read: the resolver asks for
|
||||||
|
// it, once, and decides with what comes back.
|
||||||
|
func TestResolveDuplicates_FetchesAMissingTrackList_Integration(t *testing.T) {
|
||||||
|
f := newOneAlbumFixture(t, "acoustic", "Void", "Reach")
|
||||||
|
rg := "rg-" + syncpkg.FormatUUID(f.album.ID)
|
||||||
|
if _, err := f.pool.Exec(context.Background(),
|
||||||
|
`UPDATE albums SET release_group_mbid = $2 WHERE id = $1`, f.album.ID, rg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
fetch := func(_ context.Context, asked string) error {
|
||||||
|
calls++
|
||||||
|
if asked != rg {
|
||||||
|
t.Errorf("asked for %s, want %s", asked, rg)
|
||||||
|
}
|
||||||
|
seedTrackList(t, f.pool, f.album, map[string]int{"Void": 220500, "Reach": 281000})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, err := ResolveDuplicatesFetching(context.Background(), f.pool, nil, "", true, fetch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 1 || res.WrongFiles != 1 || exists(f.bPath) {
|
||||||
|
t.Fatalf("fetches %d, result %+v; want one fetch and Reach's copy removed", calls, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Without a track list that decides, nothing is removed.
|
// Without a track list that decides, nothing is removed.
|
||||||
func TestResolveDuplicates_LeavesAnUndecidedSlotForTheOperator_Integration(t *testing.T) {
|
func TestResolveDuplicates_LeavesAnUndecidedSlotForTheOperator_Integration(t *testing.T) {
|
||||||
f := newOneAlbumFixture(t, "acoustic", "Void", "Reach")
|
f := newOneAlbumFixture(t, "acoustic", "Void", "Reach")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,10 @@ import (
|
|||||||
// Anything less certain stays with the operator.
|
// Anything less certain stays with the operator.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// trackListFetchesPerPass is how many albums' track lists a pass may ask
|
||||||
|
// MusicBrainz for, when the catalogue has never read them. The resolver
|
||||||
|
// runs hourly, so a backlog drains over a few hours without a burst.
|
||||||
|
trackListFetchesPerPass = 5
|
||||||
// slotFit is how close a file must be to a slot's MusicBrainz length to be
|
// slotFit is how close a file must be to a slot's MusicBrainz length to be
|
||||||
// that slot's song, and slotOther how far away every length of a slot's
|
// that slot's song, and slotOther how far away every length of a slot's
|
||||||
// title must be for the file to be another song's.
|
// title must be for the file to be another song's.
|
||||||
@@ -143,9 +148,14 @@ func dismissDistinct(
|
|||||||
// to settled.
|
// to settled.
|
||||||
func settleWrongSlots(
|
func settleWrongSlots(
|
||||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup, settled map[pgtype.UUID]bool,
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup, settled map[pgtype.UUID]bool,
|
||||||
|
fetch TrackListFetcher,
|
||||||
) (int, []string) {
|
) (int, []string) {
|
||||||
q := dbq.New(pool)
|
q := dbq.New(pool)
|
||||||
tracksByGroup := map[string][]dbq.MbReleaseTrack{}
|
tracksByGroup := map[string][]dbq.MbReleaseTrack{}
|
||||||
|
fetches := trackListFetchesPerPass
|
||||||
|
if fetch == nil {
|
||||||
|
fetches = 0
|
||||||
|
}
|
||||||
removed := 0
|
removed := 0
|
||||||
var names []string
|
var names []string
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
@@ -163,6 +173,18 @@ func settleWrongSlots(
|
|||||||
logger.Warn("duplicate resolve: reading the album's track list failed", "release_group", rg, "err", err)
|
logger.Warn("duplicate resolve: reading the album's track list failed", "release_group", rg, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if len(tracks) == 0 && fetches > 0 && neverRead(ctx, q, rg) {
|
||||||
|
fetches--
|
||||||
|
if err := fetch(ctx, rg); err != nil {
|
||||||
|
logger.Info("duplicate resolve: fetching the album's track list failed", "release_group", rg, "err", err)
|
||||||
|
if errors.Is(err, musicbrainz.ErrUnreachable) {
|
||||||
|
fetches = 0
|
||||||
|
}
|
||||||
|
} else if tracks, err = q.ListMBReleaseTracksForGroup(ctx, rg); err != nil {
|
||||||
|
logger.Warn("duplicate resolve: reading the album's track list failed", "release_group", rg, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
tracksByGroup[rg] = tracks
|
tracksByGroup[rg] = tracks
|
||||||
}
|
}
|
||||||
copies := make([]slotCopy, len(g.members))
|
copies := make([]slotCopy, len(g.members))
|
||||||
@@ -187,6 +209,14 @@ func settleWrongSlots(
|
|||||||
return removed, names
|
return removed, names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// neverRead reports whether the catalogue has never read a release group's
|
||||||
|
// releases. A group read and found empty is not asked about again: the
|
||||||
|
// catalogue's own refresh revisits it.
|
||||||
|
func neverRead(ctx context.Context, q *dbq.Queries, rg string) bool {
|
||||||
|
g, err := q.GetMBReleaseGroup(ctx, rg)
|
||||||
|
return errors.Is(err, pgx.ErrNoRows) || (err == nil && !g.ReleasesRefreshedAt.Valid)
|
||||||
|
}
|
||||||
|
|
||||||
// removeWrongSlots deletes each loser's file, then marks its track missing in
|
// removeWrongSlots deletes each loser's file, then marks its track missing in
|
||||||
// the same transaction that closes the group. The row is kept, with its likes
|
// the same transaction that closes the group. The row is kept, with its likes
|
||||||
// and plays: it is the slot, and a good copy fetched again takes it over. A
|
// and plays: it is the slot, and a good copy fetched again takes it over. A
|
||||||
|
|||||||
Reference in New Issue
Block a user