DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
@@ -449,3 +449,90 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refetchDuplicateRequest is the operator's answer for one recording filling
|
||||||
|
// several of an album's slots. keep_track_id is the copy whose title the
|
||||||
|
// recording is; empty means they cannot say, and every copy goes.
|
||||||
|
type refetchDuplicateRequest struct {
|
||||||
|
KeepTrackID string `json:"keep_track_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// refetchDuplicateResponse reports what went. Blocked names the releases set
|
||||||
|
// aside; empty when the album came from before Minstrel downloaded anything.
|
||||||
|
type refetchDuplicateResponse struct {
|
||||||
|
KeptTrackID *string `json:"kept_track_id"`
|
||||||
|
RemovedPaths []string `json:"removed_paths"`
|
||||||
|
Blocked []string `json:"blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRefetchDuplicateGroup implements POST
|
||||||
|
// /api/admin/library/duplicates/{id}/refetch (#6184): settle a group of one
|
||||||
|
// recording in several slots of an album the way the resolver would, with the
|
||||||
|
// operator's answer. The copies in slots that are not the recording's are
|
||||||
|
// removed and marked missing, the release they came from is set aside, and the
|
||||||
|
// album is wanted again. Merge is the wrong action there: it keeps one title
|
||||||
|
// whether or not it is the audio's, and fetches nothing.
|
||||||
|
//
|
||||||
|
// Errors:
|
||||||
|
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
|
||||||
|
// removed (fileRemoveAPIError)
|
||||||
|
// - 404 duplicate_group_not_pending when the group was already resolved
|
||||||
|
// - 400 not_wrong_slot when the group is not one recording in several slots
|
||||||
|
// - 400 survivor_not_in_group, invalid_id, invalid_body
|
||||||
|
func (h *handlers) handleRefetchDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body refetchDuplicateRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, mergeRequestBodyLimit)).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var keepID pgtype.UUID // invalid: every copy goes
|
||||||
|
if body.KeepTrackID != "" {
|
||||||
|
if keepID, ok = parseUUID(body.KeepTrackID); !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := library.SettleWrongSlot(r.Context(), h.pool, h.logger, groupID, keepID, admin.ID)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
|
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
|
||||||
|
writeErr(w, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, library.ErrDuplicateGroupNotPending):
|
||||||
|
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
||||||
|
case errors.Is(err, library.ErrNotWrongSlot):
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "not_wrong_slot")
|
||||||
|
case errors.Is(err, library.ErrSurvivorNotInGroup):
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin: refetch duplicate group", "group_id", uuidToString(groupID), "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := refetchDuplicateResponse{RemovedPaths: make([]string, 0, len(res.Removed)), Blocked: res.Blocked}
|
||||||
|
if resp.Blocked == nil {
|
||||||
|
resp.Blocked = []string{}
|
||||||
|
}
|
||||||
|
if res.Kept != nil {
|
||||||
|
id := uuidToString(res.Kept.TrackID)
|
||||||
|
resp.KeptTrackID = &id
|
||||||
|
}
|
||||||
|
for _, c := range res.Removed {
|
||||||
|
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
||||||
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
||||||
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
||||||
|
admin.Post("/library/duplicates/{id}/refetch", h.handleRefetchDuplicateGroup)
|
||||||
|
|
||||||
admin.Get("/invites", h.handleListInvites)
|
admin.Get("/invites", h.handleListInvites)
|
||||||
admin.Post("/invites", h.handleCreateInvite)
|
admin.Post("/invites", h.handleCreateInvite)
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ SELECT g.id AS group_id,
|
|||||||
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
|
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
|
||||||
WHERE g.status = 'pending'
|
WHERE g.status = 'pending'
|
||||||
AND t.missing_since IS NULL
|
AND t.missing_since IS NULL
|
||||||
|
AND ($1::uuid IS NULL OR g.id = $1::uuid)
|
||||||
ORDER BY g.id, t.id
|
ORDER BY g.id, t.id
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -366,9 +367,10 @@ type ListDuplicateGroupsForResolveRow struct {
|
|||||||
// tags or the library listing's match; ” when neither names one.
|
// tags or the library listing's match; ” when neither names one.
|
||||||
// acoustid_state and acoustid_recording are what AcoustID said of the file,
|
// acoustid_state and acoustid_recording are what AcoustID said of the file,
|
||||||
// when it was looked up (#6169: which song one recording cut to several slots
|
// when it was looked up (#6169: which song one recording cut to several slots
|
||||||
// is).
|
// is). group_id narrows it to one group, for the operator settling it (#6184);
|
||||||
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDuplicateGroupsForResolveRow, error) {
|
// NULL reads them all.
|
||||||
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve)
|
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupsForResolveRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve, groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,7 +197,8 @@ UPDATE duplicate_groups
|
|||||||
-- tags or the library listing's match; '' when neither names one.
|
-- tags or the library listing's match; '' when neither names one.
|
||||||
-- acoustid_state and acoustid_recording are what AcoustID said of the file,
|
-- acoustid_state and acoustid_recording are what AcoustID said of the file,
|
||||||
-- when it was looked up (#6169: which song one recording cut to several slots
|
-- when it was looked up (#6169: which song one recording cut to several slots
|
||||||
-- is).
|
-- is). group_id narrows it to one group, for the operator settling it (#6184);
|
||||||
|
-- NULL reads them all.
|
||||||
SELECT g.id AS group_id,
|
SELECT g.id AS group_id,
|
||||||
g.tier,
|
g.tier,
|
||||||
g.worst_bit_error_rate,
|
g.worst_bit_error_rate,
|
||||||
@@ -232,6 +233,7 @@ SELECT g.id AS group_id,
|
|||||||
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
|
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
|
||||||
WHERE g.status = 'pending'
|
WHERE g.status = 'pending'
|
||||||
AND t.missing_since IS NULL
|
AND t.missing_since IS NULL
|
||||||
|
AND (sqlc.narg(group_id)::uuid IS NULL OR g.id = sqlc.narg(group_id)::uuid)
|
||||||
ORDER BY g.id, t.id;
|
ORDER BY g.id, t.id;
|
||||||
|
|
||||||
-- name: SetDuplicateGroupClasses :exec
|
-- name: SetDuplicateGroupClasses :exec
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func ResolveDuplicatesWith(
|
|||||||
q := dbq.New(pool)
|
q := dbq.New(pool)
|
||||||
res := DuplicateResolveResult{Classes: map[DuplicateClass]int{}}
|
res := DuplicateResolveResult{Classes: map[DuplicateClass]int{}}
|
||||||
|
|
||||||
rows, err := q.ListDuplicateGroupsForResolve(ctx)
|
rows, err := q.ListDuplicateGroupsForResolve(ctx, pgtype.UUID{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, fmt.Errorf("list duplicate groups: %w", err)
|
return res, fmt.Errorf("list duplicate groups: %w", err)
|
||||||
}
|
}
|
||||||
@@ -168,12 +168,7 @@ func ResolveDuplicatesWith(
|
|||||||
res.Groups = len(groups)
|
res.Groups = len(groups)
|
||||||
|
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
cm := make([]ClassifyMember, len(g.members))
|
g.classify()
|
||||||
for i, m := range g.members {
|
|
||||||
cm[i] = ClassifyMember{AlbumID: syncpkg.FormatUUID(m.AlbumID), Title: m.Title, ArtistName: m.ArtistName}
|
|
||||||
}
|
|
||||||
g.class = ClassifyDuplicateGroup(g.tier, cm)
|
|
||||||
g.note = resolveNote(g)
|
|
||||||
res.Classes[g.class]++
|
res.Classes[g.class]++
|
||||||
}
|
}
|
||||||
if err := writeResolveVerdicts(ctx, q, groups); err != nil {
|
if err := writeResolveVerdicts(ctx, q, groups); err != nil {
|
||||||
@@ -444,6 +439,16 @@ func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGr
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// classify sets the group's class and the note the report shows beside it.
|
||||||
|
func (g *resolveGroup) classify() {
|
||||||
|
cm := make([]ClassifyMember, len(g.members))
|
||||||
|
for i, m := range g.members {
|
||||||
|
cm[i] = ClassifyMember{AlbumID: syncpkg.FormatUUID(m.AlbumID), Title: m.Title, ArtistName: m.ArtistName}
|
||||||
|
}
|
||||||
|
g.class = ClassifyDuplicateGroup(g.tier, cm)
|
||||||
|
g.note = resolveNote(g)
|
||||||
|
}
|
||||||
|
|
||||||
// resolveNote is what the report says beside a group, when there is something
|
// resolveNote is what the report says beside a group, when there is something
|
||||||
// to say.
|
// to say.
|
||||||
func resolveNote(g *resolveGroup) string {
|
func resolveNote(g *resolveGroup) string {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -45,7 +44,8 @@ import (
|
|||||||
// Whenever copies go, the release the files came from is set aside, so the
|
// Whenever copies go, the release the files came from is set aside, so the
|
||||||
// album fetched again is not the same files (#6169).
|
// album fetched again is not the same files (#6169).
|
||||||
//
|
//
|
||||||
// Anything less certain stays with the operator.
|
// Anything less certain stays with the operator, who settles it the same way
|
||||||
|
// with their own answer (SettleWrongSlot).
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// trackListFetchesPerPass is how many albums' track lists a pass may ask
|
// trackListFetchesPerPass is how many albums' track lists a pass may ask
|
||||||
@@ -239,7 +239,7 @@ func settleWrongSlots(
|
|||||||
// still does not fetch the same files again.
|
// still does not fetch the same files again.
|
||||||
blocked := blockReleasesHolding(ctx, pool, logger, g)
|
blocked := blockReleasesHolding(ctx, pool, logger, g)
|
||||||
emptied := emptiedSlots(ctx, q, logger, g, losers, tracks)
|
emptied := emptiedSlots(ctx, q, logger, g, losers, tracks)
|
||||||
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, emptied, blocked)
|
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, emptied, blocked, pgtype.UUID{})
|
||||||
removed += n
|
removed += n
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("duplicate resolve: removing wrong-slot copies failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
logger.Warn("duplicate resolve: removing wrong-slot copies failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
||||||
@@ -253,6 +253,86 @@ func settleWrongSlots(
|
|||||||
return removed, names
|
return removed, names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrNotWrongSlot means a group is not one recording filling several of an
|
||||||
|
// album's track slots, so there is no slot to empty and nothing to fetch again.
|
||||||
|
var ErrNotWrongSlot = errors.New("library: duplicate group is not one recording in several slots")
|
||||||
|
|
||||||
|
// WrongSlotResult is what settling a wrong slot by hand did. Kept is nil when
|
||||||
|
// every copy went.
|
||||||
|
type WrongSlotResult struct {
|
||||||
|
Kept *MergedCopy
|
||||||
|
Removed []MergedCopy
|
||||||
|
// Blocked names the releases set aside. None when the album was imported
|
||||||
|
// before Minstrel downloaded anything: the same files may come back.
|
||||||
|
Blocked []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettleWrongSlot settles a one-album mismatch the resolver left for the
|
||||||
|
// operator (#6184), with their answer in place of MusicBrainz's lengths and
|
||||||
|
// AcoustID's. keepID is the copy whose title they say the recording is; an
|
||||||
|
// invalid id means they cannot say, and every copy goes. Either way it is the
|
||||||
|
// resolver's own path: the release the files came from is set aside, the
|
||||||
|
// copies removed are marked missing, and the album is wanted again for them.
|
||||||
|
// A merge does none of that, and keeps one copy's title whether or not it is
|
||||||
|
// the audio's.
|
||||||
|
//
|
||||||
|
// Copies sharing the kept copy's title are one song held twice and stay, as in
|
||||||
|
// wrongSlots: the next sweep finds the pair and the resolver merges it.
|
||||||
|
func SettleWrongSlot(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groupID, keepID, actorID pgtype.UUID,
|
||||||
|
) (WrongSlotResult, error) {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
q := dbq.New(pool)
|
||||||
|
rows, err := q.ListDuplicateGroupsForResolve(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return WrongSlotResult{}, fmt.Errorf("read duplicate group: %w", err)
|
||||||
|
}
|
||||||
|
groups := foldResolveGroups(rows)
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return WrongSlotResult{}, ErrDuplicateGroupNotPending
|
||||||
|
}
|
||||||
|
g := groups[0]
|
||||||
|
if g.classify(); g.class != ClassMismatch {
|
||||||
|
return WrongSlotResult{}, ErrNotWrongSlot
|
||||||
|
}
|
||||||
|
keep := -1
|
||||||
|
if keepID.Valid {
|
||||||
|
keep = slices.IndexFunc(g.members, func(m dbq.ListDuplicateGroupsForResolveRow) bool { return m.TrackID == keepID })
|
||||||
|
if keep < 0 {
|
||||||
|
return WrongSlotResult{}, ErrSurvivorNotInGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var losers []int
|
||||||
|
for i, m := range g.members {
|
||||||
|
if keep >= 0 && MatchTitleKey(m.Title, m.ArtistName) == MatchTitleKey(g.members[keep].Title, m.ArtistName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
losers = append(losers, i)
|
||||||
|
}
|
||||||
|
if len(losers) == 0 {
|
||||||
|
return WrongSlotResult{}, ErrNotWrongSlot
|
||||||
|
}
|
||||||
|
var tracks []dbq.MbReleaseTrack
|
||||||
|
if rg := g.members[0].ReleaseGroupMbid; rg != "" {
|
||||||
|
if tracks, err = q.ListMBReleaseTracksForGroup(ctx, rg); err != nil {
|
||||||
|
return WrongSlotResult{}, fmt.Errorf("read the album's track list: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res := WrongSlotResult{Blocked: blockReleasesHolding(ctx, pool, logger, g)}
|
||||||
|
emptied := emptiedSlots(ctx, q, logger, g, losers, tracks)
|
||||||
|
n, err := removeWrongSlots(ctx, pool, logger, g, keep, losers, emptied, res.Blocked, actorID)
|
||||||
|
if keep >= 0 {
|
||||||
|
res.Kept = &MergedCopy{TrackID: g.members[keep].TrackID, FilePath: g.members[keep].FilePath}
|
||||||
|
}
|
||||||
|
for _, i := range losers[:n] {
|
||||||
|
res.Removed = append(res.Removed, MergedCopy{TrackID: g.members[i].TrackID, FilePath: g.members[i].FilePath})
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
// sameAudio reports whether a group's copies are one recording, cut however:
|
// sameAudio reports whether a group's copies are one recording, cut however:
|
||||||
// identical audio, or fingerprints all but identical (sameAudioBER).
|
// identical audio, or fingerprints all but identical (sameAudioBER).
|
||||||
func sameAudio(g *resolveGroup) bool {
|
func sameAudio(g *resolveGroup) bool {
|
||||||
@@ -442,18 +522,19 @@ func neverRead(ctx context.Context, q *dbq.Queries, rg string) bool {
|
|||||||
// are stamped to wait for a copy fetched again (emptiedSlots). blocked names
|
// are stamped to wait for a copy fetched again (emptiedSlots). blocked names
|
||||||
// the releases set aside, for the record. A file that cannot be removed stops
|
// the releases set aside, for the record. A file that cannot be removed stops
|
||||||
// the group; the ones already gone are still marked, and the group waits for
|
// the group; the ones already gone are still marked, and the group waits for
|
||||||
// the next pass.
|
// the next pass. actorID is the operator settling it by hand, invalid for the
|
||||||
|
// resolver.
|
||||||
func removeWrongSlots(
|
func removeWrongSlots(
|
||||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, g *resolveGroup, keep int, losers []int,
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, g *resolveGroup, keep int, losers []int,
|
||||||
emptied []pgtype.UUID, blocked []string,
|
emptied []pgtype.UUID, blocked []string, actorID pgtype.UUID,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var gone []pgtype.UUID
|
var gone []pgtype.UUID
|
||||||
var removedFiles []map[string]string
|
var removedFiles []map[string]string
|
||||||
var removeErr error
|
var removeErr error
|
||||||
for _, i := range losers {
|
for _, i := range losers {
|
||||||
m := g.members[i]
|
m := g.members[i]
|
||||||
if err := removeFile(m.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := removeTrackFileOnDisk(m.FilePath); err != nil {
|
||||||
removeErr = fmt.Errorf("remove %s: %w", m.FilePath, err)
|
removeErr = err
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
gone = append(gone, m.TrackID)
|
gone = append(gone, m.TrackID)
|
||||||
@@ -481,6 +562,10 @@ func removeWrongSlots(
|
|||||||
if removeErr != nil {
|
if removeErr != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if actorID.Valid {
|
||||||
|
_, err := tq.MarkDuplicateGroupMerged(ctx, g.id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
_, err := tq.SettleDuplicateGroupAutomatically(ctx, dbq.SettleDuplicateGroupAutomaticallyParams{
|
_, err := tq.SettleDuplicateGroupAutomatically(ctx, dbq.SettleDuplicateGroupAutomaticallyParams{
|
||||||
ID: g.id, Status: "merged",
|
ID: g.id, Status: "merged",
|
||||||
})
|
})
|
||||||
@@ -490,7 +575,7 @@ func removeWrongSlots(
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
details := map[string]any{
|
details := map[string]any{
|
||||||
"automatic": true,
|
"automatic": !actorID.Valid,
|
||||||
"group_id": syncpkg.FormatUUID(g.id),
|
"group_id": syncpkg.FormatUUID(g.id),
|
||||||
"artist": g.members[0].ArtistName,
|
"artist": g.members[0].ArtistName,
|
||||||
"removed": removedFiles,
|
"removed": removedFiles,
|
||||||
@@ -504,7 +589,7 @@ func removeWrongSlots(
|
|||||||
details["kept_title"], details["kept_path"] = kept.Title, kept.FilePath
|
details["kept_title"], details["kept_path"] = kept.Title, kept.FilePath
|
||||||
keptPath = kept.FilePath
|
keptPath = kept.FilePath
|
||||||
}
|
}
|
||||||
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateWrongFile, details)
|
audit.WriteOrLog(ctx, pool, logger, actorID, pgtype.UUID{}, audit.ActionDuplicateWrongFile, details)
|
||||||
logger.Info("duplicate resolve: removed copies from slots that were not theirs",
|
logger.Info("duplicate resolve: removed copies from slots that were not theirs",
|
||||||
"group_id", syncpkg.FormatUUID(g.id), "kept", keptPath, "removed", len(gone))
|
"group_id", syncpkg.FormatUUID(g.id), "kept", keptPath, "removed", len(gone))
|
||||||
return len(gone), removeErr
|
return len(gone), removeErr
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// undecidedSlot is one recording filed as Void and as Reach, two songs
|
||||||
|
// MusicBrainz lists at lengths that cannot tell them apart, from a download
|
||||||
|
// Minstrel imported: the resolver leaves it pending for the operator (#6184).
|
||||||
|
func undecidedSlot(t *testing.T) (oneAlbumFixture, dbq.User) {
|
||||||
|
t.Helper()
|
||||||
|
f := newOneAlbumFixture(t, "acoustic", "Void", "Reach")
|
||||||
|
ctx := context.Background()
|
||||||
|
seedTrackList(t, f.pool, f.album, map[string]int{"Void": 220500, "Reach": 221000})
|
||||||
|
if _, err := f.pool.Exec(ctx,
|
||||||
|
`INSERT INTO grabs (info_hash, name, key, client, save_path, origin, import_state, destination)
|
||||||
|
VALUES ('abc123', 'Savant - Void [WEB]', 'k', 'qbit', '/dl', 'schedule', 'imported', $1)`,
|
||||||
|
filepath.Dir(f.aPath)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res, err := ResolveDuplicates(ctx, f.pool, nil, "", true); err != nil || res.WrongFiles != 0 {
|
||||||
|
t.Fatalf("resolve = %+v, %v; want the group left for the operator", res, err)
|
||||||
|
}
|
||||||
|
name := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
|
||||||
|
u, err := dbq.New(f.pool).CreateUser(ctx, dbq.CreateUserParams{
|
||||||
|
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-token",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f, u
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptiedTrack says whether a copy's track is marked missing and waiting for
|
||||||
|
// the album to be fetched again.
|
||||||
|
func emptiedTrack(t *testing.T, f oneAlbumFixture, id pgtype.UUID) bool {
|
||||||
|
t.Helper()
|
||||||
|
var emptied bool
|
||||||
|
if err := f.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT missing_since IS NOT NULL AND emptied_at IS NOT NULL FROM tracks WHERE id = $1`, id).Scan(&emptied); err != nil {
|
||||||
|
t.Fatalf("track %s is gone: %v", syncpkg.FormatUUID(id), err)
|
||||||
|
}
|
||||||
|
return emptied
|
||||||
|
}
|
||||||
|
|
||||||
|
// settledByHand checks the group closed as the operator's and the audit row
|
||||||
|
// names them.
|
||||||
|
func settledByHand(t *testing.T, f oneAlbumFixture, actor dbq.User) {
|
||||||
|
t.Helper()
|
||||||
|
if status, _, _, auto := (resolveFixture{pool: f.pool, groupID: f.groupID}).group(t); status != "merged" || auto {
|
||||||
|
t.Errorf("group = (%s, auto=%v), want merged by the operator", status, auto)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := f.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM audit_log WHERE action = 'duplicate_wrong_file' AND actor_id = $1
|
||||||
|
AND metadata->>'group_id' = $2 AND NOT (metadata->>'automatic')::boolean`,
|
||||||
|
actor.ID, syncpkg.FormatUUID(f.groupID)).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("operator's wrong-file audit rows = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Get it again": the operator cannot say which song the recording is, so
|
||||||
|
// every copy goes, the download they came from is set aside, and both slots
|
||||||
|
// wait for the album fetched again. A merge would have kept one title.
|
||||||
|
func TestSettleWrongSlot_GetItAgainRemovesEveryCopy_Integration(t *testing.T) {
|
||||||
|
f, op := undecidedSlot(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
res, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, op.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Kept != nil || len(res.Removed) != 2 || exists(f.aPath) || exists(f.bPath) {
|
||||||
|
t.Fatalf("result %+v, void exists %v, reach exists %v; want both copies removed",
|
||||||
|
res, exists(f.aPath), exists(f.bPath))
|
||||||
|
}
|
||||||
|
for _, id := range []pgtype.UUID{f.a.ID, f.b.ID} {
|
||||||
|
if !emptiedTrack(t, f, id) {
|
||||||
|
t.Errorf("track %s is not waiting to be fetched again", syncpkg.FormatUUID(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !releaseBlocked(t, f.pool, "abc123") || len(res.Blocked) != 1 {
|
||||||
|
t.Errorf("blocked %v; want the download the files came from set aside", res.Blocked)
|
||||||
|
}
|
||||||
|
settledByHand(t, f, op)
|
||||||
|
|
||||||
|
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, op.ID); !errors.Is(err, ErrDuplicateGroupNotPending) {
|
||||||
|
t.Errorf("settling it twice: err = %v, want ErrDuplicateGroupNotPending", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "This one is Reach": the copy the operator names stays, and only Void's slot
|
||||||
|
// is emptied and wanted again.
|
||||||
|
func TestSettleWrongSlot_KeepsTheCopyTheOperatorNames_Integration(t *testing.T) {
|
||||||
|
f, op := undecidedSlot(t)
|
||||||
|
res, err := SettleWrongSlot(context.Background(), f.pool, nil, f.groupID, f.b.ID, op.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Kept == nil || res.Kept.TrackID != f.b.ID || len(res.Removed) != 1 || exists(f.aPath) || !exists(f.bPath) {
|
||||||
|
t.Fatalf("result %+v, void exists %v, reach exists %v; want only Void's copy removed",
|
||||||
|
res, exists(f.aPath), exists(f.bPath))
|
||||||
|
}
|
||||||
|
if !emptiedTrack(t, f, f.a.ID) {
|
||||||
|
t.Error("Void's track is not waiting to be fetched again")
|
||||||
|
}
|
||||||
|
if !releaseBlocked(t, f.pool, "abc123") {
|
||||||
|
t.Error("the download the files came from is not set aside")
|
||||||
|
}
|
||||||
|
settledByHand(t, f, op)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a group of one recording in several slots is settled this way, and only
|
||||||
|
// with a copy from it.
|
||||||
|
func TestSettleWrongSlot_RefusesWhatItCannotSettle_Integration(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
t.Run("two versions of a song", func(t *testing.T) {
|
||||||
|
f := newOneAlbumFixture(t, "acoustic", "Lovesick", "Lovesick (Instrumental)")
|
||||||
|
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, pgtype.UUID{}, pgtype.UUID{}); !errors.Is(err, ErrNotWrongSlot) {
|
||||||
|
t.Errorf("err = %v, want ErrNotWrongSlot", err)
|
||||||
|
}
|
||||||
|
if !exists(f.aPath) || !exists(f.bPath) {
|
||||||
|
t.Error("a file was removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("a copy from another group", func(t *testing.T) {
|
||||||
|
f, op := undecidedSlot(t)
|
||||||
|
other := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
|
if _, err := SettleWrongSlot(ctx, f.pool, nil, f.groupID, other, op.ID); !errors.Is(err, ErrSurvivorNotInGroup) {
|
||||||
|
t.Errorf("err = %v, want ErrSurvivorNotInGroup", err)
|
||||||
|
}
|
||||||
|
if !exists(f.aPath) || !exists(f.bPath) || releaseBlocked(t, f.pool, "abc123") {
|
||||||
|
t.Error("something changed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
AdminDuplicatesResponse,
|
AdminDuplicatesResponse,
|
||||||
DuplicatesView,
|
DuplicatesView,
|
||||||
MergeDuplicateResult,
|
MergeDuplicateResult,
|
||||||
|
RefetchDuplicateResult,
|
||||||
ResolvedDuplicatesResponse,
|
ResolvedDuplicatesResponse,
|
||||||
AdminPlaybackError,
|
AdminPlaybackError,
|
||||||
AdminQuarantineRow,
|
AdminQuarantineRow,
|
||||||
@@ -893,6 +894,18 @@ export async function mergeDuplicateGroup(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One recording in several of an album's slots (#6184): keep the copy whose
|
||||||
|
// title it is, or none (empty keep_track_id), and fetch the album again.
|
||||||
|
export async function refetchDuplicateGroup(
|
||||||
|
id: string,
|
||||||
|
body: { keep_track_id: string }
|
||||||
|
): Promise<RefetchDuplicateResult> {
|
||||||
|
return api.post<RefetchDuplicateResult>(
|
||||||
|
`/api/admin/library/duplicates/${encodeURIComponent(id)}/refetch`,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function dismissDuplicateGroup(id: string): Promise<void> {
|
export async function dismissDuplicateGroup(id: string): Promise<void> {
|
||||||
await api.post(`/api/admin/library/duplicates/${encodeURIComponent(id)}/dismiss`, {});
|
await api.post(`/api/admin/library/duplicates/${encodeURIComponent(id)}/dismiss`, {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,6 +485,15 @@ export type MergeDuplicateResult = {
|
|||||||
removed_paths: string[];
|
removed_paths: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// What settling one recording in several slots did (#6184). kept_track_id is
|
||||||
|
// null when every copy went; blocked names the releases set aside, empty when
|
||||||
|
// the album came from before Minstrel downloaded anything.
|
||||||
|
export type RefetchDuplicateResult = {
|
||||||
|
kept_track_id: string | null;
|
||||||
|
removed_paths: string[];
|
||||||
|
blocked: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminDuplicatesResponse = {
|
export type AdminDuplicatesResponse = {
|
||||||
sweep: AdminDuplicateSweep;
|
sweep: AdminDuplicateSweep;
|
||||||
fingerprints: {
|
fingerprints: {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
createResolvedDuplicatesQuery,
|
createResolvedDuplicatesQuery,
|
||||||
runDuplicateSweep,
|
runDuplicateSweep,
|
||||||
dismissDuplicateGroup,
|
dismissDuplicateGroup,
|
||||||
mergeDuplicateGroup
|
mergeDuplicateGroup,
|
||||||
|
refetchDuplicateGroup
|
||||||
} from '#lib/api/admin.js';
|
} from '#lib/api/admin.js';
|
||||||
import { errMessage } from '#lib/api/errors.js';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
@@ -38,6 +39,14 @@
|
|||||||
// song" and also undoes the link that made the copies one song (#5438). Merging (#3911) keeps one copy, moves the others'
|
// song" and also undoes the link that made the copies one song (#5438). Merging (#3911) keeps one copy, moves the others'
|
||||||
// likes, plays and playlist entries onto it, and deletes their files, so it
|
// likes, plays and playlist entries onto it, and deletes their files, so it
|
||||||
// asks twice.
|
// asks twice.
|
||||||
|
//
|
||||||
|
// Except for one recording filling several of an album's slots (class
|
||||||
|
// mismatch, #6184), where a merge is the wrong answer: it keeps one copy's
|
||||||
|
// title whether or not it is the audio's, and fetches nothing. There the
|
||||||
|
// operator says which song the recording is, or that they can't tell, and the
|
||||||
|
// group settles as the resolver would have: copies in other songs' slots are
|
||||||
|
// removed, the release they came from is set aside, and the album is fetched
|
||||||
|
// again. Operator, 2026-10-21: "for some of these I don't have any idea".
|
||||||
|
|
||||||
type Tab = DuplicatesView | 'resolved';
|
type Tab = DuplicatesView | 'resolved';
|
||||||
const fromUrl = page.url.searchParams.get('view');
|
const fromUrl = page.url.searchParams.get('view');
|
||||||
@@ -53,6 +62,11 @@
|
|||||||
let keepChoice = $state<Record<string, string>>({});
|
let keepChoice = $state<Record<string, string>>({});
|
||||||
let confirmingMerge = $state<string | null>(null);
|
let confirmingMerge = $state<string | null>(null);
|
||||||
let merging = $state<string | null>(null);
|
let merging = $state<string | null>(null);
|
||||||
|
// Per wrong-slot group: the copy whose title the recording is, absent when
|
||||||
|
// the operator can't tell, and the same two-click confirm.
|
||||||
|
let slotPick = $state<Record<string, string>>({});
|
||||||
|
let confirmingRefetch = $state<string | null>(null);
|
||||||
|
let refetching = $state<string | null>(null);
|
||||||
|
|
||||||
const queryStore = $derived(createDuplicatesQuery(groupView));
|
const queryStore = $derived(createDuplicatesQuery(groupView));
|
||||||
const query = $derived($queryStore);
|
const query = $derived($queryStore);
|
||||||
@@ -126,6 +140,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrongSlot(g: AdminDuplicateGroup): boolean {
|
||||||
|
return g.class === 'mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickedOf(g: AdminDuplicateGroup): AdminDuplicateMember | undefined {
|
||||||
|
return g.members.find((m) => m.track_id === slotPick[g.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close to the server's MatchTitleKey: case, "&" and punctuation aside.
|
||||||
|
function songKey(title: string): string {
|
||||||
|
return title.toLowerCase().replace(/&/g, 'and').replace(/[^\p{L}\p{N}]/gu, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The copies that go: every one when the operator can't tell, else those
|
||||||
|
// under another title. A copy under the picked title is the song held twice,
|
||||||
|
// and the next sweep merges it.
|
||||||
|
function refetchRemoves(g: AdminDuplicateGroup): AdminDuplicateMember[] {
|
||||||
|
const picked = pickedOf(g);
|
||||||
|
if (!picked) return g.members;
|
||||||
|
return g.members.filter((m) => songKey(m.title) !== songKey(picked.title));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRefetch(group: AdminDuplicateGroup) {
|
||||||
|
if (confirmingRefetch !== group.id) {
|
||||||
|
confirmingRefetch = group.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmingRefetch = null;
|
||||||
|
refetching = group.id;
|
||||||
|
try {
|
||||||
|
const result = await refetchDuplicateGroup(group.id, { keep_track_id: slotPick[group.id] ?? '' });
|
||||||
|
const removed = `Removed ${plural(result.removed_paths.length, 'file', 'files')}`;
|
||||||
|
pushToast(
|
||||||
|
result.blocked.length > 0
|
||||||
|
? `${removed} and set aside the release they came from. The album will be fetched again.`
|
||||||
|
: `${removed}. The album will be fetched again, but Minstrel didn't download these files, so the same ones may come back.`
|
||||||
|
);
|
||||||
|
query.refetch();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(errMessage(e), 'error');
|
||||||
|
} finally {
|
||||||
|
refetching = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// "Identical audio" and "same recording" are different claims, and an
|
// "Identical audio" and "same recording" are different claims, and an
|
||||||
// operator deciding whether to merge needs to know which one they are
|
// operator deciding whether to merge needs to know which one they are
|
||||||
// looking at before anything else.
|
// looking at before anything else.
|
||||||
@@ -377,27 +436,49 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onDismiss(group)}
|
onclick={() => onDismiss(group)}
|
||||||
disabled={dismissing === group.id || merging === group.id}
|
disabled={dismissing === group.id || merging === group.id || refetching === group.id}
|
||||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{tab === 'cross_release' ? 'Not the same song' : 'Not duplicates'}
|
{tab === 'cross_release' ? 'Not the same song' : 'Not duplicates'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{#if wrongSlot(group)}
|
||||||
type="button"
|
{@const picked = pickedOf(group)}
|
||||||
onclick={() => onMerge(group)}
|
<button
|
||||||
disabled={merging === group.id}
|
type="button"
|
||||||
class="rounded-md px-3 py-1.5 text-sm disabled:opacity-50 {confirmingMerge === group.id
|
onclick={() => onRefetch(group)}
|
||||||
? 'bg-action-destructive text-action-fg hover:opacity-90'
|
disabled={refetching === group.id}
|
||||||
: 'border border-border text-text-primary hover:bg-surface-hover'}"
|
class="rounded-md px-3 py-1.5 text-sm disabled:opacity-50 {confirmingRefetch === group.id
|
||||||
>
|
? 'bg-action-destructive text-action-fg hover:opacity-90'
|
||||||
{#if merging === group.id}
|
: 'border border-border text-text-primary hover:bg-surface-hover'}"
|
||||||
Merging…
|
>
|
||||||
{:else if confirmingMerge === group.id}
|
{#if refetching === group.id}
|
||||||
Remove {plural(group.members.length - 1, 'file', 'files')} and merge
|
Removing…
|
||||||
{:else}
|
{:else if confirmingRefetch === group.id}
|
||||||
Merge…
|
Remove {plural(refetchRemoves(group).length, 'file', 'files')} and get it again
|
||||||
{/if}
|
{:else if picked}
|
||||||
</button>
|
Keep “{picked.title}”…
|
||||||
|
{:else}
|
||||||
|
Get it again…
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onMerge(group)}
|
||||||
|
disabled={merging === group.id}
|
||||||
|
class="rounded-md px-3 py-1.5 text-sm disabled:opacity-50 {confirmingMerge === group.id
|
||||||
|
? 'bg-action-destructive text-action-fg hover:opacity-90'
|
||||||
|
: 'border border-border text-text-primary hover:bg-surface-hover'}"
|
||||||
|
>
|
||||||
|
{#if merging === group.id}
|
||||||
|
Merging…
|
||||||
|
{:else if confirmingMerge === group.id}
|
||||||
|
Remove {plural(group.members.length - 1, 'file', 'files')} and merge
|
||||||
|
{:else}
|
||||||
|
Merge…
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -424,12 +505,40 @@
|
|||||||
<span>
|
<span>
|
||||||
{group.tier === 'exact' ? 'The audio is identical' : 'The music is the same'}, but the
|
{group.tier === 'exact' ? 'The audio is identical' : 'The music is the same'}, but the
|
||||||
copies are titled {quotedList(titles)}. If those name different recordings, one file is
|
copies are titled {quotedList(titles)}. If those name different recordings, one file is
|
||||||
mislabelled and the other recording isn't in your library. Play each and keep the copy
|
mislabelled and the other recording isn't in your library.
|
||||||
whose title matches what you hear.
|
{#if wrongSlot(group)}
|
||||||
|
Play them. If you know which song this is, pick the copy with that title. If you can't
|
||||||
|
tell, get the album again: every copy goes, and it's fetched from another release.
|
||||||
|
{:else}
|
||||||
|
Play each and keep the copy whose title matches what you hear.
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmingRefetch === group.id}
|
||||||
|
{@const picked = pickedOf(group)}
|
||||||
|
<div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="refetch-confirm">
|
||||||
|
<p class="text-text-primary">
|
||||||
|
{#if picked}
|
||||||
|
The copy titled “{picked.title}” stays, and {plural(refetchRemoves(group).length, 'copy', 'copies')}
|
||||||
|
under other titles will be deleted from disk.
|
||||||
|
{:else}
|
||||||
|
All {plural(group.members.length, 'copy', 'copies')} will be deleted from disk.
|
||||||
|
{/if}
|
||||||
|
Their tracks stay, marked missing, with their likes and plays. The release the files came
|
||||||
|
from is set aside, and the album is fetched again from another one.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-text-secondary underline hover:text-text-primary"
|
||||||
|
onclick={() => (confirmingRefetch = null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if confirmingMerge === group.id}
|
{#if confirmingMerge === group.id}
|
||||||
<!-- What the second click will do, said plainly before it is done. -->
|
<!-- What the second click will do, said plainly before it is done. -->
|
||||||
<div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="merge-confirm">
|
<div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="merge-confirm">
|
||||||
@@ -449,20 +558,38 @@
|
|||||||
|
|
||||||
<ul class="divide-y divide-border">
|
<ul class="divide-y divide-border">
|
||||||
{#each group.members as m (m.track_id)}
|
{#each group.members as m (m.track_id)}
|
||||||
{@const keep = keeperOf(group) === m.track_id}
|
{@const keep = !wrongSlot(group) && keeperOf(group) === m.track_id}
|
||||||
{@const proposed = m.track_id === group.survivor_track_id}
|
{@const proposed = m.track_id === group.survivor_track_id}
|
||||||
<li class="flex items-start gap-3 px-4 py-3" data-testid="duplicate-member">
|
<li class="flex items-start gap-3 px-4 py-3" data-testid="duplicate-member">
|
||||||
<input
|
{#if wrongSlot(group)}
|
||||||
type="radio"
|
<!-- No copy is proposed: which song the recording is, is the
|
||||||
name="keep-{group.id}"
|
question, and nothing here can answer it. -->
|
||||||
class="mt-1"
|
<input
|
||||||
checked={keeperOf(group) === m.track_id}
|
type="radio"
|
||||||
onchange={() => (keepChoice[group.id] = m.track_id)}
|
name="song-{group.id}"
|
||||||
aria-label="Keep {m.title}, {m.file_format.toUpperCase()}, {m.file_path}"
|
class="mt-1"
|
||||||
/>
|
checked={slotPick[group.id] === m.track_id}
|
||||||
|
onchange={() => (slotPick[group.id] = m.track_id)}
|
||||||
|
aria-label="This is {m.title}, {m.file_path}"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="keep-{group.id}"
|
||||||
|
class="mt-1"
|
||||||
|
checked={keeperOf(group) === m.track_id}
|
||||||
|
onchange={() => (keepChoice[group.id] = m.track_id)}
|
||||||
|
aria-label="Keep {m.title}, {m.file_format.toUpperCase()}, {m.file_path}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
<div class="min-w-0 flex-1 space-y-0.5">
|
<div class="min-w-0 flex-1 space-y-0.5">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="truncate text-sm text-text-primary">{m.title}</span>
|
<span class="truncate text-sm text-text-primary">{m.title}</span>
|
||||||
|
{#if wrongSlot(group) && slotPick[group.id] === m.track_id}
|
||||||
|
<span class="shrink-0 rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg" data-testid="song-badge">
|
||||||
|
The recording is this song
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if keep}
|
{#if keep}
|
||||||
<!-- The copy the merge will keep: the proposed one with the
|
<!-- The copy the merge will keep: the proposed one with the
|
||||||
rule that chose it, or the one the operator picked. The
|
rule that chose it, or the one the operator picked. The
|
||||||
@@ -499,6 +626,15 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{#if wrongSlot(group) && pickedOf(group)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full border-t border-border px-4 py-2 text-left text-xs text-text-secondary underline hover:text-text-primary"
|
||||||
|
onclick={() => delete slotPick[group.id]}
|
||||||
|
>
|
||||||
|
I can't tell which song it is
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ vi.mock('#lib/api/admin.js', () => ({
|
|||||||
mergeDuplicateGroup: vi.fn().mockResolvedValue({
|
mergeDuplicateGroup: vi.fn().mockResolvedValue({
|
||||||
survivor_track_id: 'www-01',
|
survivor_track_id: 'www-01',
|
||||||
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
|
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
|
||||||
|
}),
|
||||||
|
refetchDuplicateGroup: vi.fn().mockResolvedValue({
|
||||||
|
kept_track_id: null,
|
||||||
|
removed_paths: ['/music/Moe Shop/WWW (2020)/www-01.mp3', '/music/Moe Shop/WWW (2020)/www-02.mp3'],
|
||||||
|
blocked: ['Moe Shop - WWW [WEB]']
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
vi.mock('#lib/player/store.svelte.js', () => ({ playQueue: vi.fn() }));
|
vi.mock('#lib/player/store.svelte.js', () => ({ playQueue: vi.fn() }));
|
||||||
@@ -26,6 +31,7 @@ import {
|
|||||||
createResolvedDuplicatesQuery,
|
createResolvedDuplicatesQuery,
|
||||||
dismissDuplicateGroup,
|
dismissDuplicateGroup,
|
||||||
mergeDuplicateGroup,
|
mergeDuplicateGroup,
|
||||||
|
refetchDuplicateGroup,
|
||||||
runDuplicateSweep
|
runDuplicateSweep
|
||||||
} from '#lib/api/admin.js';
|
} from '#lib/api/admin.js';
|
||||||
|
|
||||||
@@ -256,7 +262,64 @@ describe('admin duplicates', () => {
|
|||||||
renderWith(r);
|
renderWith(r);
|
||||||
const notice = text(screen.getByTestId('title-mismatch'));
|
const notice = text(screen.getByTestId('title-mismatch'));
|
||||||
expect(notice).toContain('The music is the same, but the copies are titled “WWW” and “WWW (instrumental)”');
|
expect(notice).toContain('The music is the same, but the copies are titled “WWW” and “WWW (instrumental)”');
|
||||||
expect(notice).toContain('Play each');
|
expect(notice).toContain("If you can't tell, get the album again");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// One recording in several of an album's slots (#6184). A merge would keep
|
||||||
|
// one title whether or not it is the audio's and fetch nothing, so it is not
|
||||||
|
// offered: the operator says which song it is, or that they can't tell.
|
||||||
|
describe('one recording in several slots', () => {
|
||||||
|
function slots() {
|
||||||
|
const r = response();
|
||||||
|
r.groups[0] = {
|
||||||
|
...r.groups[0],
|
||||||
|
tier: 'acoustic',
|
||||||
|
worst_bit_error_rate: 0.04,
|
||||||
|
class: 'mismatch',
|
||||||
|
members: [member('www-01'), member('www-02', { title: 'WWW (instrumental)' })]
|
||||||
|
};
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('offers Get it again instead of Merge, with no copy proposed', () => {
|
||||||
|
renderWith(slots());
|
||||||
|
expect(screen.queryByRole('button', { name: 'Merge…' })).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: 'Get it again…' })).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole('radio').some((r) => (r as HTMLInputElement).checked)).toBe(false);
|
||||||
|
expect(screen.queryByTestId('survivor-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can't tell: every copy goes, after a second click", async () => {
|
||||||
|
renderWith(slots());
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Get it again…' }));
|
||||||
|
expect(refetchDuplicateGroup).not.toHaveBeenCalled();
|
||||||
|
const confirm = text(screen.getByTestId('refetch-confirm'));
|
||||||
|
expect(confirm).toContain('All 2 copies will be deleted from disk');
|
||||||
|
expect(confirm).toContain('the album is fetched again from another one');
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Remove 2 files and get it again' }));
|
||||||
|
expect(refetchDuplicateGroup).toHaveBeenCalledWith('g-1', { keep_track_id: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('naming the song keeps that copy and removes the others', async () => {
|
||||||
|
renderWith(slots());
|
||||||
|
await fireEvent.click(screen.getByRole('radio', { name: /This is WWW \(instrumental\)/ }));
|
||||||
|
expect(screen.getByTestId('song-badge')).toBeTruthy();
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Keep “WWW (instrumental)”…' }));
|
||||||
|
expect(text(screen.getByTestId('refetch-confirm'))).toContain(
|
||||||
|
'The copy titled “WWW (instrumental)” stays, and 1 copy under other titles will be deleted from disk'
|
||||||
|
);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and get it again' }));
|
||||||
|
expect(refetchDuplicateGroup).toHaveBeenCalledWith('g-1', { keep_track_id: 'www-02' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("I can't tell takes the pick back", async () => {
|
||||||
|
renderWith(slots());
|
||||||
|
await fireEvent.click(screen.getAllByRole('radio')[0]);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: "I can't tell which song it is" }));
|
||||||
|
expect(screen.getByRole('button', { name: 'Get it again…' })).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId('song-badge')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user