Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails
- Auto-play next lesson on end, with a cancelable countdown and a Settings toggle (default on). - Grid-view course cards show a small progress ring (checkmark at 100%) instead of a separate bar. - Duplicate Lesson Files: scans within each course/folder for media files that look like the same lesson downloaded twice, with per-file delete and a shared ignore list with Duplicate Courses. - Auto-generated cover art now samples a few early candidate frames and keeps the largest JPEG, favoring an intro title card over a blank fade-in or a plain presenter frame. - Settings -> "Regenerate Thumbnails" re-runs that logic for every course with an auto-generated thumbnail (never touches manual covers), so already-cached thumbnails can pick up the improvement. - Add .gitignore for __pycache__/. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
2026-08-25 00:20 UTC — Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails
|
||||||
2026-08-24 23:22 UTC — Add Favorites, storage drill-down, shortcuts, and What's New
|
2026-08-24 23:22 UTC — Add Favorites, storage drill-down, shortcuts, and What's New
|
||||||
2026-08-24 19:00 UTC — Fix video resize handle drifting off-screen
|
2026-08-24 19:00 UTC — Fix video resize handle drifting off-screen
|
||||||
2026-08-24 18:06 UTC — Cache the category tree walk (fixes ~1s File Management page load)
|
2026-08-24 18:06 UTC — Cache the category tree walk (fixes ~1s File Management page load)
|
||||||
|
|||||||
@@ -101,7 +101,11 @@ silently stay blank instead of erroring.
|
|||||||
completion % at a glance
|
completion % at a glance
|
||||||
- Cover art: uses a manually-placed `cover`/`folder`/`thumbnail`/`thumb`/
|
- Cover art: uses a manually-placed `cover`/`folder`/`thumbnail`/`thumb`/
|
||||||
`poster` image if a course has one, otherwise auto-generates one via
|
`poster` image if a course has one, otherwise auto-generates one via
|
||||||
`ffmpeg` from a frame of the course's first video
|
`ffmpeg` - samples a few candidate frames from the first several seconds
|
||||||
|
of the course's first video (where an intro title card typically lives)
|
||||||
|
and keeps the one that compresses to the largest JPEG, a cheap proxy for
|
||||||
|
"has the most going on" that favors a title card/logo over a blank
|
||||||
|
fade-in or a plain frame of the presenter
|
||||||
- **File Management** ([/unsorted](templates/unsorted.html), its own item
|
- **File Management** ([/unsorted](templates/unsorted.html), its own item
|
||||||
("Files") in the bottom tab bar alongside Home/Notes/Help/Settings): a
|
("Files") in the bottom tab bar alongside Home/Notes/Help/Settings): a
|
||||||
dedicated page for everything that touches files on disk, since it needs
|
dedicated page for everything that touches files on disk, since it needs
|
||||||
@@ -181,13 +185,20 @@ silently stay blank instead of erroring.
|
|||||||
difference doesn't hide a real duplicate. Groups by similarity (union of
|
difference doesn't hide a real duplicate. Groups by similarity (union of
|
||||||
any two courses over the threshold, transitively) rather than showing
|
any two courses over the threshold, transitively) rather than showing
|
||||||
raw pairs; each course in a group gets a one-click Hide, or a **Delete**
|
raw pairs; each course in a group gets a one-click Hide, or a **Delete**
|
||||||
that permanently removes it from disk (the one destructive action in the
|
that permanently removes it from disk (confirmed with the full path
|
||||||
whole app - confirmed with the full path before it runs). A group can
|
before it runs). A group can also be marked **"Not a duplicate"** if the
|
||||||
also be marked **"Not a duplicate"** if the match is wrong, which
|
match is wrong, which excludes that specific pair from future scans
|
||||||
excludes that specific pair from future scans without touching anything
|
without touching anything else that happens to match one of those
|
||||||
else that happens to match one of those courses; ignored pairs are
|
courses; ignored pairs are listed (and reversible) under "Ignored
|
||||||
listed (and reversible) under "Ignored matches," and persist through
|
matches," and persist through backup/restore alongside hidden paths and
|
||||||
backup/restore alongside hidden paths and Next Up.
|
Next Up.
|
||||||
|
- *Duplicate Lesson Files*: the same idea one level down - media files
|
||||||
|
sitting in the same folder that look like the same lesson downloaded
|
||||||
|
twice (e.g. a re-download that landed alongside the original instead of
|
||||||
|
replacing it). Compares only within a folder, at a higher match
|
||||||
|
threshold than Duplicate Courses, so two different lessons on a similar
|
||||||
|
topic don't get flagged; shares its ignore list with Duplicate Courses.
|
||||||
|
Delete here removes a single file, not a whole course.
|
||||||
- *Clean Up Stale References*: finds entries in the hidden-paths list,
|
- *Clean Up Stale References*: finds entries in the hidden-paths list,
|
||||||
Next Up queue, Favorites, or Recently Viewed history that point at a path
|
Next Up queue, Favorites, or Recently Viewed history that point at a path
|
||||||
no longer on disk - normally from renaming/moving/deleting a course
|
no longer on disk - normally from renaming/moving/deleting a course
|
||||||
@@ -210,10 +221,15 @@ silently stay blank instead of erroring.
|
|||||||
- Recently Added / Recently Viewed
|
- Recently Added / Recently Viewed
|
||||||
- Surprise Me — dice icon in the header, random pick weighted toward
|
- Surprise Me — dice icon in the header, random pick weighted toward
|
||||||
incomplete courses
|
incomplete courses
|
||||||
|
- Grid view shows a small progress ring in the corner of each course's
|
||||||
|
thumbnail (a checkmark once complete) instead of a separate bar, so
|
||||||
|
completion reads at a glance without switching to list view
|
||||||
|
|
||||||
**Playback & progress**
|
**Playback & progress**
|
||||||
- Video/audio player with resize, playback-speed presets, and resume-from-
|
- Video/audio player with resize, playback-speed presets, and resume-from-
|
||||||
last-position
|
last-position
|
||||||
|
- Auto-play next lesson when one ends, with a cancelable few-second
|
||||||
|
countdown - on by default, toggle it off in Settings → Video Player
|
||||||
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
|
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
|
||||||
10s), ↑/↓ (volume), `,`/`.` (step playback speed), `[`/`]` (previous/next
|
10s), ↑/↓ (volume), `,`/`.` (step playback speed), `[`/`]` (previous/next
|
||||||
lesson), `F` (fullscreen), `N` (quick-capture a note) - see Help for the
|
lesson), `F` (fullscreen), `N` (quick-capture a note) - see Help for the
|
||||||
@@ -226,6 +242,11 @@ silently stay blank instead of erroring.
|
|||||||
- Settings → "Precompute Lengths & Cover Art" walks the whole library in
|
- Settings → "Precompute Lengths & Cover Art" walks the whole library in
|
||||||
the background to populate durations and thumbnails up front, with live
|
the background to populate durations and thumbnails up front, with live
|
||||||
progress
|
progress
|
||||||
|
- Settings → "Regenerate Thumbnails" deletes and re-runs auto-generated
|
||||||
|
cover art for every course that has one (manually-placed covers are
|
||||||
|
never touched) - the only way to pick up an improvement to how
|
||||||
|
thumbnails are picked on courses whose thumbnail was already cached
|
||||||
|
under the old logic, since it's otherwise served from disk forever
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
- Timestamped notes per lesson, capturable via a keyboard shortcut without
|
- Timestamped notes per lesson, capturable via a keyboard shortcut without
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026-08-24 23:22 UTC — add Favorites, storage drill-down, shortcuts, and What's New
|
2026-08-25 00:20 UTC — autoplay, progress rings, duplicate lessons, title-card thumbnails
|
||||||
|
|||||||
+204
-11
@@ -164,6 +164,7 @@ DEFAULT_SETTINGS = {
|
|||||||
'video_width': '', # '' = responsive full-width; else last dragged size, in px
|
'video_width': '', # '' = responsive full-width; else last dragged size, in px
|
||||||
'video_height': '',
|
'video_height': '',
|
||||||
'playback_speed': '1', # video/audio playback rate, as a string (see SETTINGS_CHOICES)
|
'playback_speed': '1', # video/audio playback rate, as a string (see SETTINGS_CHOICES)
|
||||||
|
'autoplay_next': True, # advance to the next lesson automatically when one ends
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bounds for the persisted video player size, to reject garbage values
|
# Bounds for the persisted video player size, to reject garbage values
|
||||||
@@ -551,6 +552,8 @@ def load_settings() -> Dict[str, Any]:
|
|||||||
elif key in VIDEO_SIZE_BOUNDS:
|
elif key in VIDEO_SIZE_BOUNDS:
|
||||||
if value == '' or (isinstance(value, int) and not isinstance(value, bool)):
|
if value == '' or (isinstance(value, int) and not isinstance(value, bool)):
|
||||||
settings[key] = value
|
settings[key] = value
|
||||||
|
elif key == 'autoplay_next' and isinstance(value, bool):
|
||||||
|
settings[key] = value
|
||||||
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
|
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
|
||||||
settings[key] = value
|
settings[key] = value
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
@@ -588,6 +591,8 @@ def save_settings(new_settings: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
if not (lo <= num <= hi):
|
if not (lo <= num <= hi):
|
||||||
raise ValueError(f"{key} must be between {lo} and {hi}")
|
raise ValueError(f"{key} must be between {lo} and {hi}")
|
||||||
current[key] = num
|
current[key] = num
|
||||||
|
elif key == 'autoplay_next' and isinstance(value, bool):
|
||||||
|
current[key] = value
|
||||||
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
|
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
|
||||||
current[key] = value
|
current[key] = value
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
@@ -913,13 +918,23 @@ def find_course_thumbnail(course_path: str) -> Optional[str]:
|
|||||||
|
|
||||||
def _generate_course_thumbnail(course_dir: Path) -> Optional[str]:
|
def _generate_course_thumbnail(course_dir: Path) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Grab a single frame from the course's first video as stand-in cover
|
Grab a frame from the course's first video as stand-in cover art, via
|
||||||
art, via ffmpeg, cached to .offlineu_thumbnail.jpg so this only ever
|
ffmpeg, cached to .offlineu_thumbnail.jpg so this only ever runs once
|
||||||
runs once per course - the next call finds that file directly through
|
per course - the next call finds that file directly through the fast
|
||||||
the fast path above instead of hitting this function again. Returns
|
path above instead of hitting this function again. Returns None
|
||||||
None (nothing cached to disk) if the course has no video at all, so a
|
(nothing cached to disk) if the course has no video at all, so a video
|
||||||
video added later is still picked up on the next request past the
|
added later is still picked up on the next request past the short
|
||||||
short in-memory cache above.
|
in-memory cache above.
|
||||||
|
|
||||||
|
Course intros typically show a title card (course/lesson name, maybe
|
||||||
|
a logo) for the first several seconds before cutting to the
|
||||||
|
presenter - a single frame at a fixed offset can easily land past
|
||||||
|
that cut and grab someone mid-sentence instead. Rather than guess one
|
||||||
|
offset, this samples a handful of candidates in the first ~8 seconds
|
||||||
|
and keeps the one with the largest resulting JPEG: a title card (text,
|
||||||
|
graphics, a logo) compresses to a noticeably bigger file than a blank
|
||||||
|
fade-in or a plain talking-head frame, which is a cheap enough proxy
|
||||||
|
for "has more going on" without any real image analysis.
|
||||||
"""
|
"""
|
||||||
video_files = sorted(
|
video_files = sorted(
|
||||||
f for f in course_dir.rglob('*')
|
f for f in course_dir.rglob('*')
|
||||||
@@ -930,19 +945,43 @@ def _generate_course_thumbnail(course_dir: Path) -> Optional[str]:
|
|||||||
source = video_files[0]
|
source = video_files[0]
|
||||||
|
|
||||||
duration = _probe_media_duration_seconds(source) or 0
|
duration = _probe_media_duration_seconds(source) or 0
|
||||||
offset = min(30.0, duration * 0.1) if duration else 5.0
|
max_offset = min(8.0, duration * 0.5) if duration else 6.0
|
||||||
|
candidate_offsets = sorted({o for o in (1.0, 2.5, 4.0, 6.0) if o <= max_offset}) or [1.0]
|
||||||
|
|
||||||
output_path = course_dir / AUTO_THUMBNAIL_FILENAME
|
output_path = course_dir / AUTO_THUMBNAIL_FILENAME
|
||||||
|
best_candidate = None
|
||||||
|
best_size = -1
|
||||||
|
candidate_paths = []
|
||||||
|
try:
|
||||||
|
for i, offset in enumerate(candidate_offsets):
|
||||||
|
candidate_path = course_dir / f'.offlineu_thumbnail_candidate_{i}.jpg'
|
||||||
|
candidate_paths.append(candidate_path)
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['ffmpeg', '-y', '-ss', str(offset), '-i', str(source),
|
['ffmpeg', '-y', '-ss', str(offset), '-i', str(source),
|
||||||
'-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '3', str(output_path)],
|
'-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '3', str(candidate_path)],
|
||||||
capture_output=True, timeout=20
|
capture_output=True, timeout=20
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
continue
|
||||||
|
if result.returncode != 0 or not candidate_path.exists():
|
||||||
|
continue
|
||||||
|
size = candidate_path.stat().st_size
|
||||||
|
if size > best_size:
|
||||||
|
best_size = size
|
||||||
|
best_candidate = candidate_path
|
||||||
|
|
||||||
|
if best_candidate is None:
|
||||||
return None
|
return None
|
||||||
if result.returncode != 0 or not output_path.exists():
|
shutil.move(str(best_candidate), str(output_path))
|
||||||
return None
|
finally:
|
||||||
|
for candidate_path in candidate_paths:
|
||||||
|
if candidate_path.exists():
|
||||||
|
try:
|
||||||
|
candidate_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
return str(output_path)
|
return str(output_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -1814,6 +1853,64 @@ def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]
|
|||||||
return groups
|
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]]]:
|
def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
|
||||||
"""
|
"""
|
||||||
Entries in hidden_paths.json / next_up.json / recent_views.json that
|
Entries in hidden_paths.json / next_up.json / recent_views.json that
|
||||||
@@ -3652,6 +3749,47 @@ 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')
|
@app.route('/api/library/duplicates')
|
||||||
def duplicate_courses_api():
|
def duplicate_courses_api():
|
||||||
"""Scan for courses that look like copies of each other by name (see
|
"""Scan for courses that look like copies of each other by name (see
|
||||||
@@ -3849,6 +3987,61 @@ def prewarm_durations_status_api():
|
|||||||
return jsonify(_duration_prewarm_state)
|
return jsonify(_duration_prewarm_state)
|
||||||
|
|
||||||
|
|
||||||
|
# Same single-process-thread pattern as the duration prewarm above.
|
||||||
|
_thumbnail_regen_state: Dict[str, Any] = {
|
||||||
|
'running': False,
|
||||||
|
'total': 0,
|
||||||
|
'done': 0,
|
||||||
|
'error': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_thumbnail_regen():
|
||||||
|
"""
|
||||||
|
Delete and regenerate every course's auto-generated thumbnail
|
||||||
|
(.offlineu_thumbnail.jpg) using _generate_course_thumbnail's current
|
||||||
|
logic - the only way to pick up an improved generation heuristic (e.g.
|
||||||
|
the title-card sampling added after thumbnails were already cached)
|
||||||
|
on courses whose thumbnail was generated before that change, since
|
||||||
|
find_course_thumbnail's normal fast path just keeps serving whatever
|
||||||
|
is already cached on disk forever. Only touches courses currently
|
||||||
|
using an auto-generated thumbnail - a manually-placed cover/folder/
|
||||||
|
thumbnail/thumb/poster file always wins and is never removed.
|
||||||
|
"""
|
||||||
|
global _thumbnail_regen_state
|
||||||
|
to_regen = [c for c in get_all_course_dirs() if (c / AUTO_THUMBNAIL_FILENAME).exists()]
|
||||||
|
_thumbnail_regen_state.update({
|
||||||
|
'running': True, 'total': len(to_regen), 'done': 0, 'error': None,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
for course_dir in to_regen:
|
||||||
|
try:
|
||||||
|
(course_dir / AUTO_THUMBNAIL_FILENAME).unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_generate_course_thumbnail(course_dir)
|
||||||
|
_thumbnail_regen_state['done'] += 1
|
||||||
|
except Exception as e:
|
||||||
|
_thumbnail_regen_state['error'] = str(e)
|
||||||
|
finally:
|
||||||
|
_thumbnail_regen_state['running'] = False
|
||||||
|
invalidate_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/library/regenerate-thumbnails', methods=['POST'])
|
||||||
|
def regenerate_thumbnails_api():
|
||||||
|
"""Kick off (or report on an already-running) background regeneration of every auto-generated course thumbnail."""
|
||||||
|
if not _thumbnail_regen_state['running']:
|
||||||
|
threading.Thread(target=_run_thumbnail_regen, daemon=True).start()
|
||||||
|
return jsonify(_thumbnail_regen_state)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/library/regenerate-thumbnails/status', methods=['GET'])
|
||||||
|
def regenerate_thumbnails_status_api():
|
||||||
|
"""Poll the current progress of a thumbnail regeneration run."""
|
||||||
|
return jsonify(_thumbnail_regen_state)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/hidden-paths', methods=['GET'])
|
@app.route('/api/hidden-paths', methods=['GET'])
|
||||||
def get_hidden_paths_api():
|
def get_hidden_paths_api():
|
||||||
"""List currently-hidden course/directory paths, with display names."""
|
"""List currently-hidden course/directory paths, with display names."""
|
||||||
|
|||||||
@@ -782,6 +782,7 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
.grid-card-thumb-wrap {
|
.grid-card-thumb-wrap {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -822,15 +823,32 @@
|
|||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.grid-card-progress-track {
|
.grid-card-progress-ring {
|
||||||
height: 3px;
|
position: absolute;
|
||||||
border-radius: 2px;
|
bottom: 6px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
right: 6px;
|
||||||
overflow: hidden;
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: conic-gradient(var(--accent) calc(var(--pct) * 1%), rgba(0, 0, 0, 0.4) 0);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
.grid-card-progress-fill {
|
.grid-card-progress-ring::before {
|
||||||
height: 100%;
|
content: '';
|
||||||
background: var(--accent);
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.grid-card-progress-ring-check {
|
||||||
|
position: relative;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transcript-search-toggle {
|
.transcript-search-toggle {
|
||||||
@@ -1704,19 +1722,17 @@
|
|||||||
: courseInitialHtml(item.name);
|
: courseInitialHtml(item.name);
|
||||||
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
|
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
|
||||||
const pct = item.completion_percentage;
|
const pct = item.completion_percentage;
|
||||||
const progressBar = pct
|
const ringHtml = pct
|
||||||
? `<div class="grid-card-progress-track"><div class="grid-card-progress-fill" style="width: ${pct}%;"></div></div>`
|
? `<div class="grid-card-progress-ring" style="--pct: ${pct};" title="${pct}% complete">${pct >= 100 ? '<span class="grid-card-progress-ring-check">✓</span>' : ''}</div>`
|
||||||
: '';
|
: '';
|
||||||
const metaBits = [`${item.media_files} media file${item.media_files === 1 ? '' : 's'}`];
|
const metaBits = [`${item.media_files} media file${item.media_files === 1 ? '' : 's'}`];
|
||||||
if (item.duration_display) metaBits.push(item.duration_display);
|
if (item.duration_display) metaBits.push(item.duration_display);
|
||||||
if (pct) metaBits.push(`${pct}% done`);
|
|
||||||
return `
|
return `
|
||||||
<div class="library-grid-card" onclick="${clickHandler}">
|
<div class="library-grid-card" onclick="${clickHandler}">
|
||||||
${selectableAttrs(item)}
|
${selectableAttrs(item)}
|
||||||
<div class="grid-card-thumb-wrap">${iconHtml}</div>
|
<div class="grid-card-thumb-wrap">${iconHtml}${ringHtml}</div>
|
||||||
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
|
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
|
||||||
<div class="grid-card-meta">${metaBits.join(' · ')}</div>
|
<div class="grid-card-meta">${metaBits.join(' · ')}</div>
|
||||||
${progressBar}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,17 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
.autoplay-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
.speed-controls {
|
.speed-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -539,6 +550,13 @@
|
|||||||
<p class="resize-hint">↘ Drag the bottom-right corner to resize the player</p>
|
<p class="resize-hint">↘ Drag the bottom-right corner to resize the player</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if next_lesson %}
|
||||||
|
<div class="autoplay-banner" id="autoplay-banner" style="display: none;">
|
||||||
|
<span>Playing next lesson in <span id="autoplay-countdown">5</span>s…</span>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="cancelAutoplay()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if lesson.audio_file %}
|
{% if lesson.audio_file %}
|
||||||
<h3>Audio</h3>
|
<h3>Audio</h3>
|
||||||
<audio controls preload="metadata" id="audio-player">
|
<audio controls preload="metadata" id="audio-player">
|
||||||
@@ -800,13 +818,16 @@
|
|||||||
// Restore the persisted playback speed and highlight the active
|
// Restore the persisted playback speed and highlight the active
|
||||||
// button; clicking a button applies it immediately and persists
|
// button; clicking a button applies it immediately and persists
|
||||||
// it via /api/settings, mirroring the video-size pattern above.
|
// it via /api/settings, mirroring the video-size pattern above.
|
||||||
|
let autoplayNextEnabled = true;
|
||||||
if (activeMedia) {
|
if (activeMedia) {
|
||||||
fetch('/api/settings')
|
fetch('/api/settings')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const speed = (data.settings || {}).playback_speed || '1';
|
const settings = data.settings || {};
|
||||||
|
const speed = settings.playback_speed || '1';
|
||||||
activeMedia.playbackRate = parseFloat(speed);
|
activeMedia.playbackRate = parseFloat(speed);
|
||||||
highlightSpeedButton(speed);
|
highlightSpeedButton(speed);
|
||||||
|
autoplayNextEnabled = settings.autoplay_next !== false;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -827,6 +848,8 @@
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NEXT_LESSON_URL = {{ ('/lesson/' ~ next_lesson)|tojson if next_lesson else 'null' }};
|
||||||
|
|
||||||
// ---- Outline topic chooser + push-on-leave ----
|
// ---- Outline topic chooser + push-on-leave ----
|
||||||
const LESSON_PATH = {{ lesson_path|tojson }};
|
const LESSON_PATH = {{ lesson_path|tojson }};
|
||||||
const LESSON_TITLE = {{ lesson.title|tojson }};
|
const LESSON_TITLE = {{ lesson.title|tojson }};
|
||||||
@@ -1210,7 +1233,43 @@
|
|||||||
activeMedia.addEventListener('ended', function() {
|
activeMedia.addEventListener('ended', function() {
|
||||||
saveProgress(activeMedia.currentTime, true);
|
saveProgress(activeMedia.currentTime, true);
|
||||||
markAsCompleted();
|
markAsCompleted();
|
||||||
|
if (NEXT_LESSON_URL && autoplayNextEnabled) startAutoplayCountdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Replaying the just-ended lesson (rather than letting it sit)
|
||||||
|
// is a clear enough signal the user doesn't want to leave yet.
|
||||||
|
activeMedia.addEventListener('play', cancelAutoplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
let autoplayTimer = null;
|
||||||
|
let autoplaySecondsLeft = 5;
|
||||||
|
|
||||||
|
function startAutoplayCountdown() {
|
||||||
|
const banner = document.getElementById('autoplay-banner');
|
||||||
|
const countdownEl = document.getElementById('autoplay-countdown');
|
||||||
|
if (!banner || !countdownEl) return;
|
||||||
|
autoplaySecondsLeft = 5;
|
||||||
|
countdownEl.textContent = autoplaySecondsLeft;
|
||||||
|
banner.style.display = 'flex';
|
||||||
|
autoplayTimer = setInterval(function() {
|
||||||
|
autoplaySecondsLeft -= 1;
|
||||||
|
if (autoplaySecondsLeft <= 0) {
|
||||||
|
clearInterval(autoplayTimer);
|
||||||
|
autoplayTimer = null;
|
||||||
|
window.location.href = NEXT_LESSON_URL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
countdownEl.textContent = autoplaySecondsLeft;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAutoplay() {
|
||||||
|
if (autoplayTimer) {
|
||||||
|
clearInterval(autoplayTimer);
|
||||||
|
autoplayTimer = null;
|
||||||
|
}
|
||||||
|
const banner = document.getElementById('autoplay-banner');
|
||||||
|
if (banner) banner.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveProgress(progressSeconds, completed = false) {
|
function saveProgress(progressSeconds, completed = false) {
|
||||||
|
|||||||
@@ -359,6 +359,12 @@
|
|||||||
<span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
<span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="setting-desc">Reads every video/audio file's length and generates cover art for any course without one, up front - so course cards in the Library show their runtime and artwork immediately instead of computing it the first time each course is viewed.</span>
|
<span class="setting-desc">Reads every video/audio file's length and generates cover art for any course without one, up front - so course cards in the Library show their runtime and artwork immediately instead of computing it the first time each course is viewed.</span>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="regen-thumbnails-btn" onclick="startThumbnailRegen()">{{ icons.icon('refresh', 14) }} Regenerate Thumbnails</button>
|
||||||
|
<span id="regen-thumbnails-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||||||
|
</div>
|
||||||
|
<span class="setting-desc">Re-runs auto-generated cover art from scratch for every course that has one (not manually-placed covers) - useful after an improvement to how thumbnails are picked, since existing ones are otherwise cached forever.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -412,6 +418,12 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<label for="autoplay_next">Auto-play next lesson
|
||||||
|
<span class="setting-desc">When a lesson finishes, automatically move on to the next one after a few seconds (cancelable)</span>
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="autoplay_next" data-setting="autoplay_next" {% if settings.autoplay_next %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -541,6 +553,10 @@
|
|||||||
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.value));
|
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input[type="checkbox"][data-setting]').forEach(el => {
|
||||||
|
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.checked));
|
||||||
|
});
|
||||||
|
|
||||||
const accentPicker = document.getElementById('accent-picker');
|
const accentPicker = document.getElementById('accent-picker');
|
||||||
const accentHex = document.getElementById('accent-hex');
|
const accentHex = document.getElementById('accent-hex');
|
||||||
|
|
||||||
@@ -648,6 +664,70 @@
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
function updateThumbnailRegenStatus(data) {
|
||||||
|
const btn = document.getElementById('regen-thumbnails-btn');
|
||||||
|
const status = document.getElementById('regen-thumbnails-status');
|
||||||
|
if (data.running) {
|
||||||
|
btn.disabled = true;
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = `Regenerating… ${data.done}/${data.total}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
if (data.error) {
|
||||||
|
status.style.color = 'var(--error)';
|
||||||
|
status.textContent = `Error: ${data.error}`;
|
||||||
|
} else if (data.total) {
|
||||||
|
status.style.color = 'var(--success)';
|
||||||
|
status.innerHTML = `${ICON_SVGS.check} Regenerated ${data.total} thumbnail${data.total === 1 ? '' : 's'}`;
|
||||||
|
setTimeout(() => { status.textContent = ''; }, 5000);
|
||||||
|
} else {
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = 'No auto-generated thumbnails to regenerate';
|
||||||
|
setTimeout(() => { status.textContent = ''; }, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollThumbnailRegenStatus() {
|
||||||
|
fetch('/api/library/regenerate-thumbnails/status')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
updateThumbnailRegenStatus(data);
|
||||||
|
if (data.running) setTimeout(pollThumbnailRegenStatus, 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startThumbnailRegen() {
|
||||||
|
const status = document.getElementById('regen-thumbnails-status');
|
||||||
|
document.getElementById('regen-thumbnails-btn').disabled = true;
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = 'Starting…';
|
||||||
|
fetch('/api/library/regenerate-thumbnails', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
updateThumbnailRegenStatus(data);
|
||||||
|
if (data.running) setTimeout(pollThumbnailRegenStatus, 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
status.style.color = 'var(--error)';
|
||||||
|
status.textContent = 'Could not reach the server';
|
||||||
|
document.getElementById('regen-thumbnails-btn').disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(function resumeThumbnailRegenStatusIfRunning() {
|
||||||
|
fetch('/api/library/regenerate-thumbnails/status')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.running) {
|
||||||
|
updateThumbnailRegenStatus(data);
|
||||||
|
setTimeout(pollThumbnailRegenStatus, 1500);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
})();
|
||||||
|
|
||||||
function handleRestoreFileChosen(input) {
|
function handleRestoreFileChosen(input) {
|
||||||
const file = input.files && input.files[0];
|
const file = input.files && input.files[0];
|
||||||
input.value = ''; // allow re-choosing the same file later
|
input.value = ''; // allow re-choosing the same file later
|
||||||
|
|||||||
@@ -687,6 +687,16 @@
|
|||||||
<div id="dup-results"></div>
|
<div id="dup-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">{{ icons.icon('clipboard', 18) }} Duplicate Lesson Files</h2>
|
||||||
|
<p class="section-desc">Scan for lesson files sitting in the same folder that look like the same lesson downloaded twice - a re-download that landed alongside the original instead of replacing it. Compares files within each folder only, with a higher match threshold than Duplicate Courses, so two genuinely different lessons on a similar topic aren't flagged.</p>
|
||||||
|
<div class="card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn btn-secondary" id="dup-lessons-scan-btn" onclick="scanDuplicateLessons()">{{ icons.icon('refresh', 14) }} Scan for Duplicate Lessons</button>
|
||||||
|
<span id="dup-lessons-status"></span>
|
||||||
|
</div>
|
||||||
|
<div id="dup-lessons-results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title">{{ icons.icon('trash', 18) }} Clean Up Stale References</h2>
|
<h2 class="section-title">{{ icons.icon('trash', 18) }} Clean Up Stale References</h2>
|
||||||
<p class="section-desc">Renaming, moving, or deleting a course directly on the NAS (instead of through this app) can leave behind references in Hidden, Next Up, or Recently Viewed that point at nothing. Review and remove them here - course files on disk are never touched.</p>
|
<p class="section-desc">Renaming, moving, or deleting a course directly on the NAS (instead of through this app) can leave behind references in Hidden, Next Up, or Recently Viewed that point at nothing. Review and remove them here - course files on disk are never touched.</p>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -1933,6 +1943,115 @@
|
|||||||
.catch(() => { btnEl.disabled = false; });
|
.catch(() => { btnEl.disabled = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Duplicate Lesson Files ----
|
||||||
|
function scanDuplicateLessons() {
|
||||||
|
const btn = document.getElementById('dup-lessons-scan-btn');
|
||||||
|
const status = document.getElementById('dup-lessons-status');
|
||||||
|
const results = document.getElementById('dup-lessons-results');
|
||||||
|
btn.disabled = true;
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = 'Scanning…';
|
||||||
|
results.innerHTML = '';
|
||||||
|
fetch('/api/library/duplicate-lessons')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
btn.disabled = false;
|
||||||
|
const groups = data.groups || [];
|
||||||
|
if (!groups.length) {
|
||||||
|
status.style.color = 'var(--success)';
|
||||||
|
status.textContent = 'No likely duplicate lesson files found.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = `${groups.length} possible duplicate group${groups.length === 1 ? '' : 's'}.`;
|
||||||
|
results.innerHTML = groups.map(group => `
|
||||||
|
<div class="dup-group">
|
||||||
|
<div class="dup-group-header">
|
||||||
|
<span>${escapeHtml(group.course_name)} — ${Math.round(group.similarity * 100)}% similar</span>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="ignoreDuplicateLessonGroup(this)">Not a duplicate - ignore</button>
|
||||||
|
</div>
|
||||||
|
${group.files.map(f => `
|
||||||
|
<div class="dup-course-row" data-path="${escapeAttr(f.path)}">
|
||||||
|
<span class="dup-course-name">${escapeHtml(f.name)}</span>
|
||||||
|
<span class="dup-course-path">${escapeHtml(f.human)}</span>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteDuplicateLessonFile(this, '${f.path.replace(/'/g, "\\'")}', '${f.name.replace(/'/g, "\\'")}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
status.style.color = 'var(--error)';
|
||||||
|
status.textContent = 'Could not reach the server.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDuplicateLessonFile(btnEl, path, name) {
|
||||||
|
if (!confirm(`Permanently delete "${name}"?\n\n${path}\n\nThis cannot be undone.`)) return;
|
||||||
|
btnEl.disabled = true;
|
||||||
|
fetch('/api/library/delete-file', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path })
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||||
|
.then(({ ok, data }) => {
|
||||||
|
if (!ok || !data.success) {
|
||||||
|
btnEl.disabled = false;
|
||||||
|
alert(data.error || 'Delete failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = btnEl.closest('.dup-course-row');
|
||||||
|
const group = row ? row.closest('.dup-group') : null;
|
||||||
|
if (row) row.remove();
|
||||||
|
if (group && group.querySelectorAll('.dup-course-row').length < 2) {
|
||||||
|
group.remove();
|
||||||
|
}
|
||||||
|
refreshDupLessonsStatusText();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btnEl.disabled = false;
|
||||||
|
alert('Could not reach the server.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDupLessonsStatusText() {
|
||||||
|
const status = document.getElementById('dup-lessons-status');
|
||||||
|
const remaining = document.querySelectorAll('#dup-lessons-results .dup-group').length;
|
||||||
|
if (remaining === 0) {
|
||||||
|
status.style.color = 'var(--success)';
|
||||||
|
status.textContent = 'No possible duplicates remaining.';
|
||||||
|
} else {
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = `${remaining} possible duplicate group${remaining === 1 ? '' : 's'} remaining.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ignoreDuplicateLessonGroup(btnEl) {
|
||||||
|
const group = btnEl.closest('.dup-group');
|
||||||
|
if (!group) return;
|
||||||
|
const paths = Array.from(group.querySelectorAll('.dup-course-row')).map(el => el.dataset.path);
|
||||||
|
if (paths.length < 2) return;
|
||||||
|
if (!confirm('Mark these as NOT duplicates of each other? They won\'t be flagged together again (reversible from "Ignored matches" under Duplicate Courses).')) return;
|
||||||
|
btnEl.disabled = true;
|
||||||
|
fetch('/api/library/duplicates/ignore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paths })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
group.remove();
|
||||||
|
refreshDupLessonsStatusText();
|
||||||
|
} else {
|
||||||
|
btnEl.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { btnEl.disabled = false; });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Stale References ----
|
// ---- Stale References ----
|
||||||
let lastStaleItems = [];
|
let lastStaleItems = [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user