New main page way of browsing the courses

This commit is contained in:
2026-08-20 18:48:17 -04:00
parent 5c5b068c4c
commit 66dff7e69f
3 changed files with 187 additions and 97 deletions
+79 -58
View File
@@ -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'],