Remove unreliable duplicate-lesson-file matching

Filename-similarity matching within a folder can't distinguish a real
re-download duplicate from a course that splits one topic across
several numbered files ("...Part 1"/"...Part 2", "-1"/"-2"/"-3") -
both look like near-100% matches by name alone, and the latter is a
common, completely normal pattern. This produced hundreds of false
positives in practice. Removes Duplicate Lesson Files entirely
(detection function, its two API routes, and the File Management UI
section); whole-course-folder matching (Duplicate Courses) is
unaffected and stays the only duplicate scan in the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 08:25:37 -04:00
co-authored by Claude Sonnet 5
parent 8220bb542a
commit 72c7f67c3d
5 changed files with 13 additions and 233 deletions
-99
View File
@@ -1864,64 +1864,6 @@ def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]
return groups
def _find_duplicate_lesson_files(min_similarity: float = 0.75) -> List[Dict[str, Any]]:
"""
Within each course, media files sitting in the same folder whose
names look like the same lesson downloaded twice - e.g. "03 Setting
Up Your Environment.mp4" and "03 Setting Up Your Environment (1).mp4"
left behind by a re-download that landed in the same section. Scoped
to siblings in the same folder, unlike Duplicate Courses' whole-library
comparison: two genuinely different lessons can share a lot of
vocabulary ("Part 1"/"Part 2" of a topic) without being duplicates, so
same-folder pairing plus a higher similarity threshold keeps this
conservative. Shares Duplicate Courses' ignore list (get_ignored_
duplicate_pairs) - a pair is just a pair of paths there, whether
they're course folders or files. Read-only.
"""
ignored_pairs = get_ignored_duplicate_pairs()
groups = []
for course_dir in get_all_course_dirs():
by_folder: Dict[Path, List[Path]] = {}
for f in course_dir.rglob('*'):
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
by_folder.setdefault(f.parent, []).append(f)
for files in by_folder.values():
if len(files) < 2:
continue
entries = [{'path': f, 'tokens': _tokenize(f.stem)} for f in files]
n = len(entries)
for i in range(n):
for j in range(i + 1, n):
path_i, path_j = str(entries[i]['path']), str(entries[j]['path'])
if tuple(sorted((path_i, path_j))) in ignored_pairs:
continue
a, b = entries[i]['tokens'], entries[j]['tokens']
union_tokens = a | b
if not union_tokens:
continue
similarity = len(a & b) / len(union_tokens)
if similarity < min_similarity:
continue
try:
size_i = entries[i]['path'].stat().st_size
size_j = entries[j]['path'].stat().st_size
except OSError:
continue
groups.append({
'course_name': course_dir.name,
'course_path': str(course_dir),
'files': sorted([
{'path': path_i, 'name': entries[i]['path'].name, 'bytes': size_i, 'human': _format_bytes(size_i)},
{'path': path_j, 'name': entries[j]['path'].name, 'bytes': size_j, 'human': _format_bytes(size_j)},
], key=lambda f: f['name'].lower()),
'similarity': round(similarity, 2),
})
groups.sort(key=lambda g: g['similarity'], reverse=True)
return groups
def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
"""
Entries in hidden_paths.json / next_up.json / recent_views.json that
@@ -3760,47 +3702,6 @@ def undo_last_action_api():
})
@app.route('/api/library/duplicate-lessons')
def duplicate_lesson_files_api():
"""Scan for lesson files within the same course/folder that look like
the same lesson downloaded twice (see _find_duplicate_lesson_files).
Manual trigger, same reasoning as Duplicate Courses."""
groups = _find_duplicate_lesson_files()
return jsonify({'groups': groups})
@app.route('/api/library/delete-file', methods=['POST'])
def delete_library_file_api():
"""
Permanently delete a single media file from disk - the file-level
counterpart to /api/library/delete, used by Duplicate Lesson Files.
Scoped to files (not directories) inside the library root with a
recognized media extension, so it can't be pointed at something else.
"""
data = request.json or {}
path = (data.get('path') or '').strip()
if not path:
return jsonify({'success': False, 'error': 'Missing path'}), 400
library_root = os.path.abspath(get_library_root())
target_abs = os.path.abspath(path)
if not target_abs.startswith(library_root + os.sep):
return jsonify({'success': False, 'error': 'Path is outside the library'}), 403
if not os.path.isfile(target_abs):
return jsonify({'success': False, 'error': 'No longer exists - already deleted?'}), 404
if Path(target_abs).suffix.lower() not in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
return jsonify({'success': False, 'error': 'Not a media file'}), 400
try:
os.remove(target_abs)
except OSError as e:
return jsonify({'success': False, 'error': str(e)}), 500
invalidate_cache()
return jsonify({'success': True})
@app.route('/api/library/duplicates')
def duplicate_courses_api():
"""Scan for courses that look like copies of each other by name (see