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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user