Allow selecting which duplicate to keep in duplicate detector
Duplicate finding detail now shows each version as clickable — user can choose which to keep instead of relying on auto-selection. Added track_number as tiebreaker in auto-pick (higher track number wins over 01, catching leftover duplicates from the playlist sync track number bug). Track number displayed in the detail view for clarity.
This commit is contained in:
parent
959bca2b8d
commit
d9e2e129b0
2 changed files with 46 additions and 10 deletions
|
|
@ -1174,14 +1174,23 @@ class RepairWorker:
|
|||
conn.close()
|
||||
|
||||
def _fix_duplicates(self, entity_type, entity_id, file_path, details):
|
||||
"""Keep the best quality duplicate and remove the rest from the database."""
|
||||
"""Keep the selected or best quality duplicate and remove the rest from the database."""
|
||||
tracks = details.get('tracks', [])
|
||||
if len(tracks) < 2:
|
||||
return {'success': False, 'error': 'Not enough duplicate info to determine best copy'}
|
||||
|
||||
# Pick best: highest bitrate, then longest duration
|
||||
best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0))
|
||||
best_id = best.get('track_id') or best.get('id')
|
||||
# If user specified which track to keep, use that
|
||||
keep_id = details.get('_fix_action')
|
||||
if keep_id:
|
||||
best = next((t for t in tracks if str(t.get('track_id') or t.get('id')) == str(keep_id)), None)
|
||||
if not best:
|
||||
return {'success': False, 'error': f'Selected track ID {keep_id} not found in duplicates'}
|
||||
best_id = keep_id
|
||||
else:
|
||||
# Auto-pick: highest bitrate, then longest duration, then highest track number (correct > 01)
|
||||
best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0, t.get('track_number', 0) or 0))
|
||||
best_id = best.get('track_id') or best.get('id')
|
||||
|
||||
if not best_id:
|
||||
return {'success': False, 'error': 'Could not determine best track ID'}
|
||||
|
||||
|
|
|
|||
|
|
@ -62757,23 +62757,27 @@ function _renderFindingDetail(f) {
|
|||
|
||||
case 'duplicate_tracks':
|
||||
if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]);
|
||||
// Determine best copy (same logic as backend: highest bitrate, then duration)
|
||||
// Determine best copy (same logic as backend: highest bitrate, then duration, then track number)
|
||||
const bestDup = d.tracks.reduce((best, t) => {
|
||||
const bBr = best.bitrate || 0, tBr = t.bitrate || 0;
|
||||
const bDur = best.duration || 0, tDur = t.duration || 0;
|
||||
return (tBr > bBr || (tBr === bBr && tDur > bDur)) ? t : best;
|
||||
const bTn = best.track_number || 0, tTn = t.track_number || 0;
|
||||
return (tBr > bBr || (tBr === bBr && tDur > bDur) || (tBr === bBr && tDur === bDur && tTn > bTn)) ? t : best;
|
||||
}, d.tracks[0]);
|
||||
const findingId = f.id;
|
||||
return media + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
|
||||
const isBest = t.id === bestDup.id;
|
||||
return `<div class="repair-detail-subitem ${isBest ? 'best' : 'removable'}">
|
||||
const tid = t.track_id || t.id;
|
||||
const isBest = (t.id === bestDup.id);
|
||||
return `<div class="repair-detail-subitem ${isBest ? 'best' : 'removable'}" style="cursor:pointer;" onclick="selectDuplicateToKeep(${findingId}, '${tid}')" title="Click to keep this version">
|
||||
<strong>
|
||||
${isBest ? '<span class="repair-keep-badge">KEEP</span>' : '<span class="repair-remove-badge">REMOVE</span>'}
|
||||
${_escFinding(t.title)} by ${_escFinding(t.artist)}
|
||||
</strong>
|
||||
<span>Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''}</span>
|
||||
<span>Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''}${t.track_number ? ` · Track #${t.track_number}` : ''}</span>
|
||||
${t.file_path ? `<span class="mono">${_escFinding(t.file_path)}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('')}</div>`;
|
||||
}).join('')}</div>
|
||||
<div style="color:rgba(255,255,255,0.3);font-size:11px;padding:4px 0;">Click on a version to keep it, or use "Keep Best" for auto-selection</div>`;
|
||||
|
||||
case 'incomplete_album':
|
||||
if (d.artist) rows.push(['Artist', d.artist]);
|
||||
|
|
@ -63048,6 +63052,29 @@ function renderRepairFindingsPagination(total, currentPage) {
|
|||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function selectDuplicateToKeep(findingId, keepTrackId) {
|
||||
if (!await showConfirmDialog({ title: 'Keep This Version', message: 'Keep this version and remove the other duplicate(s)?', confirmText: 'Keep', destructive: true })) return;
|
||||
try {
|
||||
const response = await fetch(`/api/repair/findings/${findingId}/fix`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fix_action: keepTrackId }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast(result.message || 'Duplicate resolved', 'success');
|
||||
} else {
|
||||
showToast(result.error || 'Failed to resolve duplicate', 'error');
|
||||
}
|
||||
loadRepairFindingsDashboard();
|
||||
loadRepairFindings();
|
||||
updateRepairStatus();
|
||||
} catch (error) {
|
||||
console.error('Error fixing duplicate:', error);
|
||||
showToast('Error resolving duplicate', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fixRepairFinding(id, findingType) {
|
||||
// Orphan files require user to choose an action
|
||||
let fixAction = null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue