#!/usr/bin/env python3 """ OfflineU - Self-hosted Course Viewer & Tracker Enhanced version with dynamic subdirectory navigation """ import os import json import mimetypes import re import sys import argparse from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict from typing import List, Dict, Optional, Any, Tuple from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for app = Flask(__name__) app.config['SECRET_KEY'] = 'your-secret-key-change-in-production' # Supported file types VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'} AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'} SUBTITLE_EXTENSIONS = {'.srt', '.vtt', '.ass', '.sub', '.sbv'} TEXT_EXTENSIONS = {'.txt', '.md', '.html', '.htm', '.pdf', '.docx', '.doc', '.rtf'} QUIZ_INDICATORS = {'quiz', 'exam', 'test', 'assessment', 'exercise', 'assignment', 'homework'} # Base directory the "Library" browser scans for courses, so users don't have # to type a full filesystem path. Matches the ./courses volume mount in # docker-compose.yml by default; override with --library-path or the # COURSES_LIBRARY_PATH env var. LIBRARY_PATH = os.environ.get('COURSES_LIBRARY_PATH', '/app/courses') # App-wide (non per-course) persisted data, e.g. display settings. Matches # the ./data volume mount in docker-compose.yml. DATA_DIR = os.environ.get('OFFLINEU_DATA_DIR', '/app/data') SETTINGS_FILE = os.path.join(DATA_DIR, 'settings.json') DEFAULT_SETTINGS = { 'theme': 'dark', # 'dark' | 'light' 'accent_color': '#007acc', # any #rrggbb hex 'font_family': 'system', # 'system' | 'sans' | 'serif' | 'monospace' 'font_size': 'medium', # 'small' | 'medium' | 'large' | 'xlarge' 'layout_width': 'wide', # 'normal' | 'wide' | 'full' '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 'video_width': '', # '' = responsive full-width; else last dragged size, in px 'video_height': '', } # Bounds for the persisted video player size, to reject garbage values # without capping how big/small someone can reasonably drag it. VIDEO_SIZE_BOUNDS = {'video_width': (200, 4000), 'video_height': (120, 3000)} # Allowed values for every setting except accent_color (validated separately # as a hex string). Anything outside these sets is rejected/ignored on save. SETTINGS_CHOICES = { 'theme': { 'dark', 'light', 'dracula', 'tokyo_night', 'catppuccin_mocha', 'ayu_dark', 'github_dark', 'atom_one_dark', 'houston', 'night_owl', 'nord', 'matcha', }, 'font_family': {'system', 'sans', 'serif', 'monospace', 'monaspace'}, 'font_size': {'small', 'medium', 'large', 'xlarge'}, 'layout_width': {'normal', 'wide', 'full'}, 'density': {'comfortable', 'compact'}, 'card_style': {'flat', 'elevated', 'bordered'}, 'corner_radius': {'sharp', 'rounded', 'pill'}, } # Display names for the theme dropdown - only needed where the raw key # (e.g. 'tokyo_night') isn't already a clean label via .capitalize(). THEME_DISPLAY_NAMES = { 'dark': 'Dark', 'light': 'Light', 'dracula': 'Dracula', 'tokyo_night': 'Tokyo Night', 'catppuccin_mocha': 'Catppuccin Mocha', 'ayu_dark': 'Ayu Dark', 'github_dark': 'GitHub Dark', 'atom_one_dark': 'Atom One Dark', 'houston': 'Houston', 'night_owl': 'Night Owl', 'nord': 'Nord', 'matcha': 'Matcha', } # Full color palette per theme. 'accent' here is only the *default* accent # offered when a theme is first selected - the accent_color setting is what # actually drives --accent afterward, so it stays independently editable. # Sourced from each theme's official palette (Dracula, Tokyo Night, # Catppuccin Mocha, Ayu, GitHub Dark, Atom One Dark, Houston, Nord all # verified against upstream repos/specs). Matcha is sourced from # lucafalasco/matcha's published VS Code theme JSON (editor/sideBar/ # activityBar backgrounds, foreground, panel.border, and the # statusBar/button accent color). # # Dainty used to be here instead of Nord, but it turned out to be a # Lab-space theme *generator* (HotWordland/dainty-vscode) with no fixed # shipped palette - its output depends on whatever base theme you feed # it, so there was no single "official Dainty" hex set to verify our # approximation against. Swapped for Nord, which does have one. THEME_PALETTES = { 'dark': { 'bg-primary': '#1a1a1a', 'bg-secondary': '#2d2d2d', 'bg-tertiary': '#3d3d3d', 'bg-tertiary-hover': '#404040', 'text-primary': '#e0e0e0', 'text-muted': '#999999', 'border-color': '#555555', 'accent': '#007acc', 'accent-hover': '#005a9e', }, 'light': { 'bg-primary': '#f2f2f2', 'bg-secondary': '#ffffff', 'bg-tertiary': '#eeeeee', 'bg-tertiary-hover': '#e2e2e2', 'text-primary': '#222222', 'text-muted': '#666666', 'border-color': '#cccccc', 'accent': '#007acc', 'accent-hover': '#005a9e', }, 'dracula': { 'bg-primary': '#282a36', 'bg-secondary': '#343746', 'bg-tertiary': '#44475a', 'bg-tertiary-hover': '#4d5066', 'text-primary': '#f8f8f2', 'text-muted': '#6272a4', 'border-color': '#44475a', 'accent': '#bd93f9', 'accent-hover': '#a672f0', }, 'tokyo_night': { 'bg-primary': '#1a1b26', 'bg-secondary': '#1f2335', 'bg-tertiary': '#283457', 'bg-tertiary-hover': '#364a73', 'text-primary': '#c0caf5', 'text-muted': '#565f89', 'border-color': '#292e42', 'accent': '#7aa2f7', 'accent-hover': '#6183bb', }, 'catppuccin_mocha': { 'bg-primary': '#1e1e2e', 'bg-secondary': '#313244', 'bg-tertiary': '#45475a', 'bg-tertiary-hover': '#585b70', 'text-primary': '#cdd6f4', 'text-muted': '#a6adc8', 'border-color': '#585b70', 'accent': '#cba6f7', 'accent-hover': '#b48ee0', }, 'ayu_dark': { 'bg-primary': '#0a0e14', 'bg-secondary': '#131721', 'bg-tertiary': '#1f2430', 'bg-tertiary-hover': '#272d3b', 'text-primary': '#b3b1ad', 'text-muted': '#626d7a', 'border-color': '#1f2430', 'accent': '#e6b450', 'accent-hover': '#ffb454', }, 'github_dark': { 'bg-primary': '#0d1117', 'bg-secondary': '#161b22', 'bg-tertiary': '#21262d', 'bg-tertiary-hover': '#30363d', 'text-primary': '#e6edf3', 'text-muted': '#8b949e', 'border-color': '#30363d', 'accent': '#2f81f7', 'accent-hover': '#1f6feb', }, 'atom_one_dark': { 'bg-primary': '#282c34', 'bg-secondary': '#2c313a', 'bg-tertiary': '#3b414d', 'bg-tertiary-hover': '#454b59', 'text-primary': '#abb2bf', 'text-muted': '#5c6370', 'border-color': '#3b414d', 'accent': '#61afef', 'accent-hover': '#528bff', }, 'houston': { 'bg-primary': '#17191e', 'bg-secondary': '#2b2d33', 'bg-tertiary': '#34363d', 'bg-tertiary-hover': '#3d3f47', 'text-primary': '#eef0f9', 'text-muted': '#79808f', 'border-color': '#2b2d33', 'accent': '#acafff', 'accent-hover': '#54b9ff', }, 'night_owl': { 'bg-primary': '#011627', 'bg-secondary': '#001122', 'bg-tertiary': '#0b2942', 'bg-tertiary-hover': '#123a5c', 'text-primary': '#d6deeb', 'text-muted': '#5f7e97', 'border-color': '#102a44', 'accent': '#82aaff', 'accent-hover': '#6690e0', }, 'nord': { 'bg-primary': '#2e3440', 'bg-secondary': '#3b4252', 'bg-tertiary': '#434c5e', 'bg-tertiary-hover': '#4c566a', 'text-primary': '#d8dee9', 'text-muted': '#4c566a', 'border-color': '#434c5e', 'accent': '#88c0d0', 'accent-hover': '#5e81ac', }, 'matcha': { 'bg-primary': '#1c2427', 'bg-secondary': '#273136', 'bg-tertiary': '#323e45', 'bg-tertiary-hover': '#3c4850', 'text-primary': '#d1ded3', 'text-muted': '#7c8885', 'border-color': '#707c4f', 'accent': '#a4b07e', 'accent-hover': '#8b966b', }, } # How each choice resolves to an actual CSS value. Keeping this server-side # (rather than duplicating the mapping in JS) means the client just applies # whatever /api/settings hands back. FONT_FAMILY_CSS = { 'system': "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", 'sans': "Arial, Helvetica, sans-serif", 'serif': "Georgia, 'Times New Roman', serif", 'monospace': "'Courier New', Consolas, monospace", # Monaspace is GitHub's monospace font superfamily, not a color theme - # falls back to Consolas/monospace if the font isn't installed locally, # same as any other web font declaration. 'monaspace': "'Monaspace Neon', 'Monaspace Argon', 'Cascadia Code', Consolas, monospace", } FONT_SIZE_CSS = {'small': '14px', 'medium': '16px', 'large': '18px', 'xlarge': '20px'} LAYOUT_WIDTH_CSS = {'normal': '1000px', 'wide': '1600px', 'full': '100%'} RADIUS_CSS = {'sharp': '2px', 'rounded': '8px', 'pill': '999px'} HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$') def load_settings() -> Dict[str, Any]: """Load persisted display settings, filling in defaults for anything missing/invalid.""" settings = dict(DEFAULT_SETTINGS) try: if os.path.exists(SETTINGS_FILE): with open(SETTINGS_FILE, 'r') as f: saved = json.load(f) 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 VIDEO_SIZE_BOUNDS: if value == '' or (isinstance(value, int) and not isinstance(value, bool)): settings[key] = value elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]: settings[key] = value except (json.JSONDecodeError, OSError) as e: print(f"Could not load settings, using defaults: {e}") return settings def save_settings(new_settings: Dict[str, Any]) -> Dict[str, Any]: """Validate and persist display settings; unknown/invalid keys are ignored.""" current = load_settings() 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 == 'theme' and value in SETTINGS_CHOICES['theme']: current['theme'] = value # Switching to a named preset also adopts its signature accent, # unless this same request is also setting accent_color # explicitly (in which case that wins). if 'accent_color' not in new_settings: current['accent_color'] = THEME_PALETTES[value]['accent'] 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 VIDEO_SIZE_BOUNDS: if value in (None, ''): current[key] = '' continue try: num = int(value) except (TypeError, ValueError): raise ValueError(f"{key} must be a number") lo, hi = VIDEO_SIZE_BOUNDS[key] if not (lo <= num <= hi): raise ValueError(f"{key} must be between {lo} and {hi}") current[key] = num elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]: current[key] = value os.makedirs(DATA_DIR, exist_ok=True) with open(SETTINGS_FILE, 'w') as f: json.dump(current, f, indent=2) return current def settings_css_vars(settings: Dict[str, Any]) -> Dict[str, str]: """Resolve a settings dict into the actual CSS custom-property values.""" palette = THEME_PALETTES.get(settings['theme'], THEME_PALETTES['dark']) return { '--accent': settings['accent_color'], '--accent-hover': palette['accent-hover'], '--bg-primary': palette['bg-primary'], '--bg-secondary': palette['bg-secondary'], '--bg-tertiary': palette['bg-tertiary'], '--bg-tertiary-hover': palette['bg-tertiary-hover'], '--text-primary': palette['text-primary'], '--text-muted': palette['text-muted'], '--border-color': palette['border-color'], '--font-family': FONT_FAMILY_CSS[settings['font_family']], '--font-size-base': FONT_SIZE_CSS[settings['font_size']], '--container-max-width': LAYOUT_WIDTH_CSS[settings['layout_width']], '--radius': RADIUS_CSS[settings['corner_radius']], } @dataclass class Lesson: title: str path: str lesson_type: str # 'video', 'audio', 'text', 'quiz', 'mixed' video_file: Optional[str] = None audio_file: Optional[str] = None subtitle_file: Optional[str] = None text_files: List[str] = None completed: bool = False last_accessed: Optional[str] = None progress_seconds: int = 0 duration_seconds: int = 0 order: int = 0 def __post_init__(self): if self.text_files is None: self.text_files = [] @dataclass class DirectoryNode: """Represents a directory in the course structure""" name: str path: str type: str # 'directory' or 'lesson' children: Dict[str, 'DirectoryNode'] = None lessons: List[Lesson] = None completed: bool = False last_accessed: Optional[str] = None order: int = 0 has_content: bool = False # Whether this directory contains actual lesson content def __post_init__(self): if self.children is None: self.children = {} if self.lessons is None: self.lessons = [] @dataclass class Course: name: str path: str root_node: DirectoryNode progress_file: str last_accessed_path: Optional[str] = None completion_percentage: float = 0.0 def __post_init__(self): if self.root_node is None: self.root_node = DirectoryNode("", "", "directory") class DynamicCourseParser: """Enhanced parser that builds a proper directory tree structure""" @staticmethod def scan_directory(course_path: str) -> Course: """Scan directory and build dynamic tree structure""" course_path = Path(course_path) if not course_path.exists() or not course_path.is_dir(): raise ValueError(f"Invalid course path: {course_path}") course_name = course_path.name print(f"Scanning course: {course_name}") # Build the directory tree root_node = DynamicCourseParser._build_directory_tree(course_path, course_path) # Calculate completion statistics stats = DynamicCourseParser._calculate_completion_stats(root_node) progress_file = str(course_path / ".offlineu_progress.json") return Course( name=course_name, path=str(course_path), root_node=root_node, progress_file=progress_file ) @staticmethod def _build_directory_tree(course_path: Path, current_path: Path, depth: int = 0) -> DirectoryNode: """Recursively build directory tree structure""" if depth > 10: # Prevent infinite recursion return DirectoryNode(current_path.name, str(current_path), "directory") node_name = current_path.name if current_path != course_path else "Course Root" node = DirectoryNode( name=node_name, path=str(current_path), type="directory", order=depth ) try: # Get all items in current directory items = sorted(current_path.iterdir(), key=lambda x: (x.is_file(), x.name.lower())) for item in items: if item.name.startswith('.'): continue if item.is_dir(): # Recursively process subdirectory child_node = DynamicCourseParser._build_directory_tree(course_path, item, depth + 1) if child_node.has_content or child_node.children: node.children[child_node.name] = child_node node.has_content = True elif item.is_file(): # Process file as potential lesson content lesson = DynamicCourseParser._create_lesson_from_file(item, course_path) if lesson: # Add lesson directly to this node's lessons list node.lessons.append(lesson) node.has_content = True except (PermissionError, OSError) as e: print(f"Error accessing {current_path}: {e}") return node @staticmethod def _create_lesson_from_file(file_path: Path, course_path: Path) -> Optional[Lesson]: """Create a lesson from a single file""" ext = file_path.suffix.lower() filename = file_path.name.lower() # Skip non-content files if ext in {'.log', '.tmp', '.bak', '.swp', '.DS_Store', '.Thumbs.db'}: return None # Determine lesson type and files video_file = None audio_file = None subtitle_file = None text_files = [] lesson_type = 'text' # Create relative path for file serving - normalize to forward slashes relative_path = str(file_path.relative_to(course_path)).replace('\\', '/') if ext in VIDEO_EXTENSIONS: video_file = relative_path lesson_type = 'video' elif ext in AUDIO_EXTENSIONS: audio_file = relative_path lesson_type = 'audio' elif ext in SUBTITLE_EXTENSIONS: subtitle_file = relative_path return None # Don't create lessons for subtitle files alone elif ext in TEXT_EXTENSIONS: text_files.append(relative_path) if any(indicator in filename for indicator in QUIZ_INDICATORS): lesson_type = 'quiz' else: # Skip unsupported file types return None # Clean up lesson name for display display_name = DynamicCourseParser._clean_lesson_name(file_path.stem) return Lesson( title=display_name, path=str(file_path), # Store the actual file path, not just parent lesson_type=lesson_type, video_file=video_file, audio_file=audio_file, subtitle_file=subtitle_file, text_files=text_files, order=0 ) @staticmethod def _clean_lesson_name(name: str) -> str: """Clean up lesson name for display""" # Remove common patterns name = re.sub(r'^\d+[\.\-_\s]*', '', name) # Remove leading numbers name = re.sub(r'[-_]+', ' ', name) # Replace dashes/underscores with spaces name = ' '.join(word.capitalize() for word in name.split() if word) return name if name.strip() else "Untitled Lesson" @staticmethod def _calculate_completion_stats(node: DirectoryNode) -> Dict[str, Any]: """Calculate completion statistics for a directory node""" total_lessons = 0 completed_lessons = 0 def count_lessons_recursive(n: DirectoryNode): nonlocal total_lessons, completed_lessons # Count lessons in this node for lesson in n.lessons: total_lessons += 1 if lesson.completed: completed_lessons += 1 # Recursively count in children for child in n.children.values(): count_lessons_recursive(child) count_lessons_recursive(node) completion_percentage = (completed_lessons / total_lessons * 100) if total_lessons > 0 else 0 return { 'total_lessons': total_lessons, 'completed_lessons': completed_lessons, 'completion_percentage': round(completion_percentage, 1) } def _has_direct_media(directory: Path) -> bool: """Check whether a directory contains media files directly (not recursively)""" try: for f in directory.iterdir(): if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS: return True except (PermissionError, OSError): pass return False _SECTION_NAME_RE = re.compile( r'^(section|module|chapter|part|unit|lesson)\b', re.IGNORECASE ) def _looks_like_course(directory: Path) -> bool: """ Heuristic for 'this folder is a course, stop recursing into it': it has media files directly, or at least two of its immediate subfolders do (covers the common Section 1/, Section 2/... layout). Requiring 2+ matching subfolders (rather than just 1) avoids mistaking a publisher/grouping folder that holds a single course - e.g. Pluralsight/Docker Deep Dive/lesson.mp4 - for the course itself. The one exception: a directory with exactly one media-holding subfolder whose name reads as a section label ("Section 1", "Module 2", ...) rather than a course title. That's still a course with just one section, not a publisher folder - Pluralsight/Docker Deep Dive doesn't get named "Section 1", so this doesn't reopen the ambiguity above. """ if _has_direct_media(directory): return True try: children = [ child for child in directory.iterdir() if child.is_dir() and not child.name.startswith('.') ] except (PermissionError, OSError): return False children_with_media = [child for child in children if _has_direct_media(child)] if len(children_with_media) >= 2: return True if ( len(children_with_media) == 1 and _SECTION_NAME_RE.match(children_with_media[0].name.strip()) ): return True return False 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 _contains_visible_course(directory: Path, hidden_set: set) -> bool: """ Whether `directory` leads to at least one course that isn't curated out - directly, or via a hidden ancestor folder within this subtree. Used by the normal (skip_hidden=True) Library browser so a folder whose every course has been individually hidden doesn't still show up as a drillable directory that dead-ends empty once opened. Hiding a folder hides everything inside it, so a hidden folder short-circuits the walk rather than counting anything beneath it as visible. """ if os.path.abspath(str(directory)) in hidden_set: return False if _looks_like_course(directory): return True try: children = [ c for c in directory.iterdir() if c.is_dir() and not c.name.startswith('.') ] except (PermissionError, OSError): return False return any(_contains_visible_course(child, hidden_set) for child in children) 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 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. 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}"]} 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: 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('*') if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS ]) items.append({ 'type': 'course', 'name': entry.name, 'path': entry_path_str, 'media_files': media_count, 'hidden': is_hidden }) else: if skip_hidden: # Only count courses that aren't themselves curated out - # otherwise a folder whose courses are all hidden # individually would still show up, empty, once opened. has_course_inside = _contains_visible_course(entry, hidden_set) else: has_course_inside = any( f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS for f in entry.rglob('*') ) if has_course_inside: items.append({ 'type': 'directory', 'name': entry.name, 'path': entry_path_str, 'hidden': is_hidden }) return {'items': items, '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 RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json') MAX_RECENT_VIEWS = 5 def record_recent_view(course_name: str, course_path: str, lesson_path: str, lesson_title: str) -> None: """ Record a lesson view for the cross-course 'Recently Viewed' list on the dashboard, so jumping back to where you left off doesn't require re-browsing the library - even for a different course than whichever one happens to be loaded right now. """ entries = get_recent_views() # Drop any existing entry for this exact lesson so it moves to the # front instead of appearing twice. entries = [ e for e in entries if not (e.get('course_path') == course_path and e.get('lesson_path') == lesson_path) ] entries.insert(0, { 'course_name': course_name, 'course_path': course_path, 'lesson_path': lesson_path, 'lesson_title': lesson_title, 'viewed_at': datetime.now().isoformat() }) entries = entries[:MAX_RECENT_VIEWS] try: os.makedirs(DATA_DIR, exist_ok=True) with open(RECENT_VIEWS_FILE, 'w') as f: json.dump(entries, f, indent=2) except OSError as e: print(f"Could not save recent views: {e}") def get_recent_views() -> List[Dict[str, Any]]: """Load the persisted 'Recently Viewed' list, newest first.""" try: if os.path.exists(RECENT_VIEWS_FILE): with open(RECENT_VIEWS_FILE, 'r') as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: print(f"Could not load recent views: {e}") 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 and watch progress added.""" entries = get_recent_views() for entry in entries: try: dt = datetime.fromisoformat(entry['viewed_at']) 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 class ProgressTracker: """Handles progress tracking and persistence""" @staticmethod def load_progress(course: Course) -> Dict[str, Any]: """Load progress from JSON file""" try: with open(course.progress_file, 'r') as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} @staticmethod def save_progress(course: Course, progress_data: Dict[str, Any]): """Save progress to JSON file""" try: with open(course.progress_file, 'w') as f: json.dump(progress_data, f, indent=2) except Exception as e: print(f"Error saving progress: {e}") @staticmethod 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) 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 ProgressTracker.save_progress(course, progress) @staticmethod def apply_progress_to_tree(course: Course): """Apply saved progress to the course tree""" progress = ProgressTracker.load_progress(course) def apply_to_node(node: DirectoryNode): # Apply progress to lessons in this node for lesson in node.lessons: lesson_path = os.path.relpath(lesson.path, course.path) lesson_path = lesson_path.replace('\\', '/') if lesson_path.startswith('/'): lesson_path = lesson_path[1:] # Check both the base path and path with title lesson_path_with_title = f"{lesson_path}/{lesson.title.replace(' ', '_')}" if lesson_path in progress: 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(): apply_to_node(child) apply_to_node(course.root_node) course.last_accessed_path = progress.get('last_accessed_path') @staticmethod def get_completion_stats(course: Course) -> Dict[str, Any]: """Calculate completion statistics""" return DynamicCourseParser._calculate_completion_stats(course.root_node) # Global course storage current_course = None @app.route('/') def index(): """Main dashboard""" global current_course if current_course is None: # Show dashboard with course selection option return render_template('course_dashboard.html', course=None, stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0}, recent_views=get_recent_views_for_display()) # Apply progress data to tree ProgressTracker.apply_progress_to_tree(current_course) stats = ProgressTracker.get_completion_stats(current_course) return render_template('course_dashboard.html', course=current_course, stats=stats, recent_views=get_recent_views_for_display()) @app.route('/browse') def browse_directories(): """Browse directories for course selection""" path = request.args.get('path', '') try: # If no path specified, start with available drives on Windows if not path: import platform if platform.system() == 'Windows': # Get available drives on Windows import string drives = [] for letter in string.ascii_uppercase: drive = f"{letter}:\\" if os.path.exists(drive): drives.append({ 'name': f"Drive {letter}:", 'path': drive, 'media_files': 0, 'is_course_candidate': False }) print(f"Returning {len(drives)} drives") return jsonify({ 'current_path': 'Select a Drive', 'parent_path': None, 'directories': drives }) else: # On other systems, start from home directory path = str(Path.home()) current_path = Path(path) if not current_path.exists() or not current_path.is_dir(): # Fallback to home directory if path doesn't exist current_path = Path.home() print(f"Browsing directory: {current_path}") # Get directories and basic info directories = [] try: for item in sorted(current_path.iterdir()): if item.is_dir() and not item.name.startswith('.'): try: # Check if this looks like a course directory media_count = len([f for f in item.rglob('*') if f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS]) directories.append({ 'name': item.name, 'path': str(item), 'media_files': media_count, 'is_course_candidate': media_count > 0 }) except (PermissionError, OSError): directories.append({ 'name': item.name + " (Access Denied)", 'path': str(item), 'media_files': 0, 'is_course_candidate': False }) except (PermissionError, OSError) as e: print(f"Access denied to {current_path}: {str(e)}") return jsonify({'error': f'Access denied to {current_path}: {str(e)}'}), 403 # Determine parent path parent = None if current_path.parent != current_path: try: parent = str(current_path.parent) except (PermissionError, OSError): pass print(f"Found {len(directories)} directories") return jsonify({ 'current_path': str(current_path), 'parent_path': parent, 'directories': directories }) except Exception as e: print(f"Error in browse_directories: {str(e)}") return jsonify({'error': str(e)}), 500 @app.route('/library') def browse_library(): """ 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_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_root, 'current_path': target_path, 'items': result['items'], 'errors': result['errors'] }) @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.""" current = load_settings() named_presets = sorted( (t for t in SETTINGS_CHOICES['theme'] if t not in ('dark', 'light')), key=lambda t: THEME_DISPLAY_NAMES[t] ) return render_template( 'settings.html', settings=current, default_library_path=LIBRARY_PATH, theme_choices=['dark', 'light'] + named_presets, theme_display_names=THEME_DISPLAY_NAMES, font_family_choices=['system', 'sans', 'serif', 'monospace', 'monaspace'], font_size_choices=['small', 'medium', 'large', 'xlarge'], layout_width_choices=['normal', 'wide', 'full'], density_choices=['comfortable', 'compact'], card_style_choices=['flat', 'elevated', 'bordered'], corner_radius_choices=['sharp', 'rounded', 'pill'], ) @app.route('/api/settings', methods=['GET']) def get_settings_api(): """Return current settings plus their resolved CSS values, for the shared theme script.""" current = load_settings() return jsonify({ 'settings': current, 'css_vars': settings_css_vars(current) }) @app.route('/api/settings', methods=['POST']) def save_settings_api(): """Persist updated display settings.""" try: new_settings = request.get_json(force=True) or {} saved = save_settings(new_settings) return jsonify({ 'success': True, 'settings': saved, 'css_vars': settings_css_vars(saved) }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 400 @app.route('/api/settings/reset', methods=['POST']) def reset_settings_api(): """Reset settings back to defaults.""" os.makedirs(DATA_DIR, exist_ok=True) with open(SETTINGS_FILE, 'w') as f: json.dump(DEFAULT_SETTINGS, f, indent=2) return jsonify({ 'success': True, 'settings': DEFAULT_SETTINGS, 'css_vars': settings_css_vars(DEFAULT_SETTINGS) }) @app.route('/load_course', methods=['POST']) def load_course(): """Load course from selected directory""" global current_course data = request.json course_path = data.get('course_path') if not course_path or not os.path.exists(course_path): return jsonify({'error': 'Invalid course path'}), 400 try: current_course = DynamicCourseParser.scan_directory(course_path) return jsonify({'success': True, 'course_name': current_course.name}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/recent/open') def open_recent(): """ Jump straight to a "Recently Viewed" entry from the dashboard: load its course if it isn't already the active one, then go to that lesson. """ global current_course course_path = request.args.get('course_path', '') lesson_path = request.args.get('lesson_path', '') if not course_path or not lesson_path or not os.path.exists(course_path): return redirect(url_for('index')) try: if not current_course or current_course.path != course_path: current_course = DynamicCourseParser.scan_directory(course_path) except Exception as e: print(f"Could not load course for recent view: {e}") return redirect(url_for('index')) return redirect(url_for('view_lesson', lesson_path=lesson_path)) @app.route('/help') def help_page(): """Static help page: how to use the app, supported file types.""" return render_template('help.html') @app.route('/lesson/') def view_lesson(lesson_path: str): """View specific lesson by path""" global current_course if not current_course: return redirect(url_for('index')) # Find the lesson in the tree lesson = find_lesson_in_tree(current_course.root_node, lesson_path) if not lesson: return redirect(url_for('index')) # Get all lessons for navigation all_lessons = get_all_lessons(current_course.root_node) current_index = -1 # Find current lesson index for i, (path, lesson_obj) in enumerate(all_lessons): if lesson_obj == lesson: current_index = i break # Get next and previous lessons prev_lesson = None next_lesson = None if current_index > 0: prev_lesson = all_lessons[current_index - 1][0] if current_index < len(all_lessons) - 1: next_lesson = all_lessons[current_index + 1][0] # Update last accessed ProgressTracker.update_lesson_progress(current_course, lesson_path) # Record for the cross-course "Recently Viewed" list on the dashboard record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title) return render_template('lesson_view.html', course=current_course, lesson=lesson, lesson_path=lesson_path, prev_lesson=prev_lesson, next_lesson=next_lesson) def get_lesson_url(lesson: Lesson, course_path: str) -> str: """Generate the URL for a lesson""" # Create relative path from course root lesson_file_path = os.path.relpath(lesson.path, course_path) lesson_file_path = lesson_file_path.replace('\\', '/') if lesson_file_path.startswith('/'): lesson_file_path = lesson_file_path[1:] # Append lesson title for uniqueness lesson_url = f"{lesson_file_path}/{lesson.title.replace(' ', '_')}" return lesson_url def find_lesson_in_tree(node: DirectoryNode, target_path: str) -> Optional[Lesson]: """Find a lesson in the tree by path""" # Check lessons in current node for lesson in node.lessons: lesson_url = get_lesson_url(lesson, current_course.path) # Check multiple possible path formats lesson_file_path = os.path.relpath(lesson.path, current_course.path) lesson_file_path = lesson_file_path.replace('\\', '/') if lesson_file_path.startswith('/'): lesson_file_path = lesson_file_path[1:] # Also check with lesson title appended lesson_path_with_title = f"{lesson_file_path}/{lesson.title.replace(' ', '_')}" if (lesson_url == target_path or lesson_file_path == target_path or lesson_path_with_title == target_path): return lesson # Recursively search children for child in node.children.values(): result = find_lesson_in_tree(child, target_path) if result: return result return None def get_all_lessons(node: DirectoryNode) -> List[Tuple[str, Lesson]]: """Get all lessons from the tree with their paths""" lessons = [] def collect_lessons(n: DirectoryNode, current_path: str = ""): # Add lessons from this node for lesson in n.lessons: lesson_url = get_lesson_url(lesson, current_course.path) lessons.append((lesson_url, lesson)) # Recursively collect from children for child in n.children.values(): collect_lessons(child, current_path) collect_lessons(node) return lessons @app.route('/api/progress', methods=['POST']) def update_progress(): """API endpoint to update lesson progress""" global current_course if not current_course: return jsonify({'error': 'No course loaded'}), 400 data = request.json 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, duration_seconds ) return jsonify({'success': True}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/files/') def serve_file(filepath): """Serve course files""" global current_course if not current_course: return "No course loaded", 404 # Security: ensure file is within course directory try: # URL decode the filepath and normalize it from urllib.parse import unquote decoded_filepath = unquote(filepath) # Construct the full path relative to the course directory full_path = os.path.join(current_course.path, decoded_filepath) full_path = os.path.abspath(full_path) course_path = os.path.abspath(current_course.path) print(f"File request: {filepath}") print(f"Decoded filepath: {decoded_filepath}") print(f"Full path: {full_path}") print(f"Course path: {course_path}") # Security check: ensure file is within course directory if not full_path.startswith(course_path): print(f"Access denied: {full_path} not in {course_path}") return "Access denied", 403 if not os.path.exists(full_path): print(f"File not found: {full_path}") return "File not found", 404 print(f"Serving file: {full_path}") # Determine MIME type. Don't rely solely on mimetypes.guess_type() - # its results can vary by OS/container base image depending on what # system mime databases are present. Force the types that matter for # inline preview (PDF above all) so this can't silently regress. ext = os.path.splitext(full_path)[1].lower() KNOWN_MIME_TYPES = { '.pdf': 'application/pdf', '.html': 'text/html', '.htm': 'text/html', '.txt': 'text/plain', '.md': 'text/plain', } if ext in KNOWN_MIME_TYPES: mime_type = KNOWN_MIME_TYPES[ext] else: mime_type, _ = mimetypes.guess_type(full_path) if mime_type is None: mime_type = 'application/octet-stream' # as_attachment=False (the default) sends Content-Disposition: inline # so the browser renders PDFs/HTML in the iframe instead of prompting # a download. Being explicit here so this can't drift. return send_file(full_path, mimetype=mime_type, as_attachment=False) except Exception as e: print(f"Error serving file: {str(e)}") return f"Error serving file: {str(e)}", 500 @app.route('/health') def healthcheck(): """Healthcheck endpoint for Docker""" return jsonify({"status": "healthy"}), 200 @app.route('/reset_course') def reset_course(): """Reset current course selection""" global current_course current_course = None return redirect(url_for('index')) def create_templates(): """Create basic template files if they don't exist""" templates_dir = Path('templates') templates_dir.mkdir(exist_ok=True) # Basic select course template select_template = ''' OfflineU - Select Course

OfflineU - Course Selection

''' # Basic course dashboard template dashboard_template = ''' OfflineU - {{ course.name }}

{{ course.name }}

Progress: {{ stats.completed_lessons }}/{{ stats.total_lessons }} lessons ({{ stats.completion_percentage }}%)

{% for module_idx, module in course.modules|enumerate %}

{{ module.title }}

{% for lesson_idx, lesson in module.lessons|enumerate %} {% endfor %}
{% endfor %}

Select Different Course

''' # Basic lesson view template lesson_template = ''' {{ lesson.title }} - {{ course.name }}

{{ lesson.title }}

{% if lesson.video_file %}

Video

{% endif %} {% if lesson.audio_file %}

Audio

{% endif %} {% if lesson.text_files %}

Additional Resources

{% for text_file in lesson.text_files %} 📄 {{ text_file.split('/')[-1] }} {% endfor %} {% endif %}
''' # Write templates to files template_files = { 'select_course.html': select_template, 'course_dashboard.html': dashboard_template, 'lesson_view.html': lesson_template } for filename, content in template_files.items(): template_path = templates_dir / filename if not template_path.exists(): with open(template_path, 'w', encoding='utf-8') as f: f.write(content) print(f"Created template: {template_path}") if __name__ == '__main__': parser = argparse.ArgumentParser(description='OfflineU Course Viewer & Tracker') parser.add_argument('--host', default='0.0.0.0', help='Host to bind to') parser.add_argument('--port', type=int, default=5000, help='Port to bind to') parser.add_argument('--debug', action='store_true', help='Enable debug mode') parser.add_argument('--create-templates', action='store_true', help='Create basic templates') parser.add_argument('--library-path', default=None, help='Base directory to scan for courses in the Library browser ' '(default: $COURSES_LIBRARY_PATH or /app/courses)') parser.add_argument('course_path', nargs='?', help='Path to course directory') args = parser.parse_args() if args.library_path: LIBRARY_PATH = args.library_path # Create templates if requested if args.create_templates: create_templates() print("Templates created successfully!") if not args.course_path: sys.exit(0) # Auto-load course if provided if args.course_path or os.environ.get('AUTO_LOAD_COURSE'): course_path = args.course_path or os.environ.get('AUTO_LOAD_COURSE') if not os.path.exists(course_path): print(f"Error: Course path does not exist: {course_path}", file=sys.stderr) sys.exit(1) try: current_course = DynamicCourseParser.scan_directory(course_path) print(f"Auto-loaded course: {current_course.name}") print(f"Built dynamic directory tree with {len(current_course.root_node.children)} top-level items") except Exception as e: print(f"Error loading course: {e}", file=sys.stderr) if args.debug: import traceback traceback.print_exc() sys.exit(1) # Create templates directory if it doesn't exist if not Path('templates').exists(): print("Templates directory not found. Creating basic templates...") create_templates() print(f"Starting OfflineU on http://{args.host}:{args.port}") print("Use --create-templates to regenerate template files") try: app.run(debug=args.debug, host=args.host, port=args.port) except KeyboardInterrupt: print("\nShutting down OfflineU...") except Exception as e: print(f"Error starting server: {e}") if args.debug: import traceback traceback.print_exc() sys.exit(1)