diff --git a/offlineu_core.py b/offlineu_core.py index 2f01b37..c341fbc 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -46,6 +46,7 @@ DEFAULT_SETTINGS = { 'density': 'comfortable', # 'comfortable' | 'compact' 'card_style': 'flat', # 'flat' | 'elevated' | 'bordered' 'corner_radius': 'rounded', # 'sharp' | 'rounded' | 'pill' + 'library_path': '', # '' = use COURSES_LIBRARY_PATH/--library-path default } # Allowed values for every setting except accent_color (validated separately @@ -86,6 +87,10 @@ def load_settings() -> Dict[str, Any]: for key, value in saved.items(): if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(value): settings[key] = value + elif key == 'library_path' and isinstance(value, str): + # Lenient on load (directory may be transiently + # unavailable at startup) - only validated on save. + settings[key] = value elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]: settings[key] = value except (json.JSONDecodeError, OSError) as e: @@ -99,6 +104,11 @@ def save_settings(new_settings: Dict[str, Any]) -> Dict[str, Any]: for key, value in new_settings.items(): if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(value): current[key] = value + elif key == 'library_path' and isinstance(value, str): + value = value.strip() + if value and not os.path.isdir(value): + raise ValueError(f"Directory not found: {value}") + current[key] = value elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]: current[key] = value os.makedirs(DATA_DIR, exist_ok=True) @@ -364,69 +374,69 @@ def _looks_like_course(directory: Path) -> bool: return len(children_with_media) >= 2 -def scan_course_library(library_path: str, max_depth: int = 5) -> Dict[str, Any]: +def list_library_directory(dir_path: str) -> Dict[str, Any]: """ - Scan a base 'library' directory for course folders and group them by the - parent directory they live in, so the UI can show something like: + List only the immediate children of dir_path for the lazy-loading + Library browser: subdirectories to drill into, and course folders to + load directly - without recursively scanning the whole tree. This is + what lets the UI start collapsed at the top level and expand on demand + instead of rendering hundreds of courses at once. - Udemy/ - Python Bootcamp - Web Dev Masterclass - Pluralsight/ - Docker Deep Dive - (Library Root) - Standalone Course - - Recursion stops as soon as a directory looks like a course (see - _looks_like_course), so nested "Section" folders inside a course aren't - mistaken for more courses. + 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. """ - library_root = Path(library_path) - groups: Dict[str, List[Dict[str, Any]]] = {} - errors: List[str] = [] + directory = Path(dir_path) + items: List[Dict[str, Any]] = [] - if not library_root.exists() or not library_root.is_dir(): - return {'groups': groups, 'errors': [f"Library path not found: {library_path}"]} + if not directory.exists() or not directory.is_dir(): + return {'items': items, 'errors': [f"Directory not found: {dir_path}"]} - def walk(current: Path, depth: int): - if depth > max_depth: - return - try: - entries = sorted( - (p for p in current.iterdir() if p.is_dir() and not p.name.startswith('.')), - key=lambda p: p.name.lower() + try: + entries = sorted( + (p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')), + key=lambda p: p.name.lower() + ) + except (PermissionError, OSError) as e: + return {'items': items, 'errors': [f"Could not read {dir_path}: {e}"]} + + for entry in entries: + if _looks_like_course(entry): + media_count = len([ + f for f in entry.rglob('*') + if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS + ]) + items.append({ + 'type': 'course', + 'name': entry.name, + 'path': str(entry), + 'media_files': media_count + }) + else: + has_course_inside = any( + f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS + for f in entry.rglob('*') ) - except (PermissionError, OSError) as e: - errors.append(f"Could not read {current}: {e}") - return - - for entry in entries: - if _looks_like_course(entry): - try: - rel_parent = entry.parent.relative_to(library_root) - except ValueError: - rel_parent = Path('.') - group_label = str(rel_parent).replace('\\', '/') if str(rel_parent) != '.' else '(Library Root)' - - media_count = len([ - f for f in entry.rglob('*') - if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS - ]) - - groups.setdefault(group_label, []).append({ + if has_course_inside: + items.append({ + 'type': 'directory', 'name': entry.name, 'path': str(entry), - 'media_files': media_count }) - else: - walk(entry, depth + 1) - walk(library_root, 0) + return {'items': items, 'errors': []} - for courses in groups.values(): - courses.sort(key=lambda c: c['name'].lower()) - return {'groups': groups, 'errors': errors} +def get_library_root() -> str: + """ + The effective root the Library browser scans: the persisted + 'library_path' setting if it's set and exists, otherwise the + COURSES_LIBRARY_PATH/--library-path default. + """ + settings = load_settings() + override = (settings.get('library_path') or '').strip() + if override and os.path.isdir(override): + return override + return LIBRARY_PATH class ProgressTracker: @@ -617,15 +627,25 @@ def browse_directories(): @app.route('/library') def browse_library(): """ - Scan the configured courses library and return courses grouped by - parent directory, so the UI can offer a click-through picker instead - of requiring a typed filesystem path. + Lazily list one directory level of the courses library at a time, so + the UI can start collapsed at the top level and drill down on click + instead of scanning/rendering the whole tree upfront. Defaults to the + effective library root (persisted setting, or the + COURSES_LIBRARY_PATH/--library-path default); the 'path' query param + lets the browser descend, but is restricted to stay within that root. """ - library_path = request.args.get('path', LIBRARY_PATH) - result = scan_course_library(library_path) + 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) return jsonify({ - 'library_path': library_path, - 'groups': result['groups'], + 'library_path': library_root, + 'current_path': target_path, + 'items': result['items'], 'errors': result['errors'] }) @@ -637,6 +657,7 @@ def settings_page(): return render_template( 'settings.html', settings=current, + default_library_path=LIBRARY_PATH, theme_choices=sorted(SETTINGS_CHOICES['theme']), font_family_choices=['system', 'sans', 'serif', 'monospace'], font_size_choices=['small', 'medium', 'large', 'xlarge'], diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 2773c82..009aac2 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -680,55 +680,84 @@ function loadLibrary() { const libraryCard = document.getElementById('library-card'); if (!libraryCard) return; + fetchLibraryLevel(null, document.getElementById('library-groups'), true); + } - fetch('/library') + function fetchLibraryLevel(path, container, isRoot) { + const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library'; + fetch(url) .then(r => r.json()) .then(data => { - document.getElementById('library-path-bar').textContent = - `Scanning ${data.library_path}`; - - const container = document.getElementById('library-groups'); - const groupNames = Object.keys(data.groups).sort((a, b) => a.localeCompare(b)); - - if (groupNames.length === 0) { - const reason = (data.errors && data.errors.length) - ? data.errors.join(' ') - : `No course folders found under ${data.library_path}.`; - container.innerHTML = `
${reason} You can still load a course by path below.
`; - return; + if (isRoot) { + document.getElementById('library-path-bar').textContent = + `Scanning ${data.library_path}`; } - - container.innerHTML = groupNames.map((group, i) => ` -Could not reach the library scanner.
'; }); } + function renderLibraryLevel(data, container, isRoot) { + if (!data.items || data.items.length === 0) { + const reason = (data.errors && data.errors.length) + ? data.errors.join(' ') + : (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.'); + container.innerHTML = `${reason}${isRoot ? ' You can still load a course by path below.' : ''}
`; + return; + } + + container.innerHTML = data.items.map(item => { + if (item.type === 'course') { + return ` +Loading...
'; + content.dataset.loaded = 'true'; + fetchLibraryLevel(path, content, false); + } + function loadCoursePath(path) { fetch('/load_course', { method: 'POST', diff --git a/templates/settings.html b/templates/settings.html index 5852d42..94ccb98 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -312,6 +312,21 @@ +