diff --git a/offlineu_core.py b/offlineu_core.py index 68df164..4ffa2d8 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -267,6 +267,7 @@ class Lesson: completed: bool = False last_accessed: Optional[str] = None progress_seconds: int = 0 + duration_seconds: int = 0 order: int = 0 def __post_init__(self): @@ -501,7 +502,38 @@ def _looks_like_course(directory: Path) -> bool: return len(children_with_media) >= 2 -def list_library_directory(dir_path: str) -> Dict[str, Any]: +HIDDEN_PATHS_FILE = os.path.join(DATA_DIR, 'hidden_paths.json') + + +def get_hidden_paths() -> List[str]: + """Load the set of course/directory paths curated out of the Library browser.""" + try: + if os.path.exists(HIDDEN_PATHS_FILE): + with open(HIDDEN_PATHS_FILE, 'r') as f: + data = json.load(f) + if isinstance(data, list): + return data + except (json.JSONDecodeError, OSError) as e: + print(f"Could not load hidden paths: {e}") + return [] + + +def set_path_hidden(path: str, hidden: bool) -> List[str]: + """Add or remove a path from the hidden set; returns the updated list.""" + paths = set(get_hidden_paths()) + normalized = os.path.abspath(path) + if hidden: + paths.add(normalized) + else: + paths.discard(normalized) + result = sorted(paths) + os.makedirs(DATA_DIR, exist_ok=True) + with open(HIDDEN_PATHS_FILE, 'w') as f: + json.dump(result, f, indent=2) + return result + + +def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, Any]: """ List only the immediate children of dir_path for the lazy-loading Library browser: subdirectories to drill into, and course folders to @@ -511,9 +543,16 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]: Directories that don't lead to any course anywhere inside them are left out entirely, so drilling down never dead-ends on an empty folder. + + Items the user has curated out (see get_hidden_paths) are excluded + when skip_hidden=True (normal browsing). When skip_hidden=False, they + are included but flagged with 'hidden': True instead - used by the + Settings-page curation UI, which needs to show hidden items so they + can be un-hidden. """ directory = Path(dir_path) items: List[Dict[str, Any]] = [] + hidden_set = set(get_hidden_paths()) if not directory.exists() or not directory.is_dir(): return {'items': items, 'errors': [f"Directory not found: {dir_path}"]} @@ -527,6 +566,11 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]: return {'items': items, 'errors': [f"Could not read {dir_path}: {e}"]} for entry in entries: + entry_path_str = str(entry) + is_hidden = os.path.abspath(entry_path_str) in hidden_set + if skip_hidden and is_hidden: + continue + if _looks_like_course(entry): media_count = len([ f for f in entry.rglob('*') @@ -535,8 +579,9 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]: items.append({ 'type': 'course', 'name': entry.name, - 'path': str(entry), - 'media_files': media_count + 'path': entry_path_str, + 'media_files': media_count, + 'hidden': is_hidden }) else: has_course_inside = any( @@ -547,7 +592,8 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]: items.append({ 'type': 'directory', 'name': entry.name, - 'path': str(entry), + 'path': entry_path_str, + 'hidden': is_hidden }) return {'items': items, 'errors': []} @@ -614,8 +660,26 @@ def get_recent_views() -> List[Dict[str, Any]]: return [] +def _read_lesson_progress_entry(course_path: str, lesson_path: str) -> Dict[str, Any]: + """ + Read a single lesson's progress entry directly from its course's own + progress file, without needing that course to be the currently loaded + one. Used to show watch progress for 'Recently Viewed' entries that may + belong to a different course than whatever's active right now. + """ + if not course_path or not lesson_path: + return {} + progress_file = os.path.join(course_path, '.offlineu_progress.json') + try: + with open(progress_file, 'r') as f: + progress = json.load(f) + return progress.get(lesson_path, {}) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + + def get_recent_views_for_display() -> List[Dict[str, Any]]: - """Recent views with a human-friendly timestamp added for the template.""" + """Recent views with a human-friendly timestamp and watch progress added.""" entries = get_recent_views() for entry in entries: try: @@ -623,6 +687,19 @@ def get_recent_views_for_display() -> List[Dict[str, Any]]: entry['viewed_display'] = dt.strftime('%b %d, %I:%M %p').replace(' 0', ' ') except (KeyError, ValueError): entry['viewed_display'] = '' + + lesson_progress = _read_lesson_progress_entry(entry.get('course_path', ''), entry.get('lesson_path', '')) + completed = lesson_progress.get('completed', False) + progress_seconds = lesson_progress.get('progress_seconds', 0) + duration_seconds = lesson_progress.get('duration_seconds', 0) + + entry['completed'] = completed + if completed: + entry['percent_watched'] = 100 + elif duration_seconds: + entry['percent_watched'] = max(0, min(100, round(100 * progress_seconds / duration_seconds))) + else: + entry['percent_watched'] = 0 return entries @@ -648,16 +725,26 @@ class ProgressTracker: print(f"Error saving progress: {e}") @staticmethod - def update_lesson_progress(course: Course, lesson_path: str, completed: bool = False, progress_seconds: int = 0): + def update_lesson_progress(course: Course, lesson_path: str, completed: bool = False, + progress_seconds: int = 0, duration_seconds: Optional[int] = None): """Update progress for specific lesson by path""" progress = ProgressTracker.load_progress(course) - - progress[lesson_path] = { + existing = progress.get(lesson_path, {}) + + entry = { 'completed': completed, 'progress_seconds': progress_seconds, 'last_accessed': datetime.now().isoformat() } - + # Preserve a previously-known duration if this particular save + # didn't report one, rather than clobbering it back to unknown. + if duration_seconds: + entry['duration_seconds'] = duration_seconds + elif existing.get('duration_seconds'): + entry['duration_seconds'] = existing['duration_seconds'] + + progress[lesson_path] = entry + # Update last accessed path progress['last_accessed_path'] = lesson_path @@ -683,10 +770,12 @@ class ProgressTracker: lesson.completed = progress[lesson_path].get('completed', False) lesson.last_accessed = progress[lesson_path].get('last_accessed') lesson.progress_seconds = progress[lesson_path].get('progress_seconds', 0) + lesson.duration_seconds = progress[lesson_path].get('duration_seconds', 0) elif lesson_path_with_title in progress: lesson.completed = progress[lesson_path_with_title].get('completed', False) lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed') lesson.progress_seconds = progress[lesson_path_with_title].get('progress_seconds', 0) + lesson.duration_seconds = progress[lesson_path_with_title].get('duration_seconds', 0) # Recursively apply to children for child in node.children.values(): @@ -839,6 +928,60 @@ def browse_library(): }) +@app.route('/library/manage') +def browse_library_manage(): + """ + Same as /library, but includes items the user has hidden (flagged + 'hidden': true rather than filtered out), so the Settings-page + curation UI can browse the whole tree and toggle visibility. + """ + library_root = os.path.abspath(get_library_root()) + requested_path = request.args.get('path', library_root) + target_path = os.path.abspath(requested_path) + + if not (target_path == library_root or target_path.startswith(library_root + os.sep)): + return jsonify({'error': 'Path outside library root', 'items': [], 'errors': ['Access denied']}), 403 + + result = list_library_directory(target_path, skip_hidden=False) + return jsonify({ + 'library_path': library_root, + 'current_path': target_path, + 'items': result['items'], + 'errors': result['errors'] + }) + + +@app.route('/api/hidden-paths', methods=['GET']) +def get_hidden_paths_api(): + """List currently-hidden course/directory paths, with display names.""" + paths = get_hidden_paths() + return jsonify({ + 'hidden_paths': [ + {'path': p, 'name': os.path.basename(p.rstrip(os.sep)) or p} + for p in paths + ] + }) + + +@app.route('/api/hidden-paths', methods=['POST']) +def set_hidden_path_api(): + """Hide or un-hide a course/directory from the Library browser.""" + data = request.json or {} + path = data.get('path', '') + hidden = bool(data.get('hidden', True)) + + if not path: + return jsonify({'error': 'path is required'}), 400 + + library_root = os.path.abspath(get_library_root()) + target = os.path.abspath(path) + if not (target == library_root or target.startswith(library_root + os.sep)): + return jsonify({'error': 'Path outside library root'}), 403 + + updated = set_path_hidden(target, hidden) + return jsonify({'success': True, 'hidden_paths': updated}) + + @app.route('/settings') def settings_page(): """Render the display-settings page.""" @@ -1068,10 +1211,11 @@ def update_progress(): lesson_path = data.get('lesson_path') completed = data.get('completed', False) progress_seconds = data.get('progress_seconds', 0) + duration_seconds = data.get('duration_seconds') or None try: ProgressTracker.update_lesson_progress( - current_course, lesson_path, completed, progress_seconds + current_course, lesson_path, completed, progress_seconds, duration_seconds ) return jsonify({'success': True}) except Exception as e: diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index affcfb3..8d5dd09 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -349,6 +349,8 @@ transition: all 0.3s; cursor: pointer; border-left: 3px solid #666; + position: relative; + overflow: hidden; } .lesson-item:hover { @@ -366,6 +368,38 @@ background: #2d5a2d; } + .lesson-item.in-progress { + border-left-color: var(--accent); + } + + .lesson-progress-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(255, 255, 255, 0.08); + } + + .lesson-progress-fill { + height: 100%; + background: var(--accent); + } + + .lesson-item.completed .lesson-progress-fill { + background: #28a745; + } + + .watched-badge { + font-size: 0.75em; + background: var(--bg-primary); + color: var(--accent); + padding: 2px 7px; + border-radius: 3px; + border: 1px solid var(--accent); + white-space: nowrap; + } + .lesson-title { display: flex; align-items: center; @@ -458,25 +492,6 @@
+ Hide courses or whole folders from the Library browser without touching anything on disk. + Hiding a folder hides everything inside it. +
+ + + +Could not reach the library scanner.
'; + }); + } + + function renderCurateLevel(data, container) { + if (!data.items || data.items.length === 0) { + const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.'; + container.innerHTML = `