New main page way of browsing the courses
This commit is contained in:
+79
-58
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user