1196 lines
44 KiB
Python
1196 lines
44 KiB
Python
#!/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'
|
|
}
|
|
|
|
# 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'},
|
|
'font_family': {'system', 'sans', 'serif', 'monospace'},
|
|
'font_size': {'small', 'medium', 'large', 'xlarge'},
|
|
'layout_width': {'normal', 'wide', 'full'},
|
|
'density': {'comfortable', 'compact'},
|
|
'card_style': {'flat', 'elevated', 'bordered'},
|
|
'corner_radius': {'sharp', 'rounded', 'pill'},
|
|
}
|
|
|
|
# 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",
|
|
}
|
|
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 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 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."""
|
|
return {
|
|
'--accent': settings['accent_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
|
|
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
|
|
|
|
|
|
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.
|
|
"""
|
|
if _has_direct_media(directory):
|
|
return True
|
|
try:
|
|
children_with_media = [
|
|
child for child in directory.iterdir()
|
|
if child.is_dir() and not child.name.startswith('.') and _has_direct_media(child)
|
|
]
|
|
except (PermissionError, OSError):
|
|
return False
|
|
return len(children_with_media) >= 2
|
|
|
|
|
|
def scan_course_library(library_path: str, max_depth: int = 5) -> 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:
|
|
|
|
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.
|
|
"""
|
|
library_root = Path(library_path)
|
|
groups: Dict[str, List[Dict[str, Any]]] = {}
|
|
errors: List[str] = []
|
|
|
|
if not library_root.exists() or not library_root.is_dir():
|
|
return {'groups': groups, 'errors': [f"Library path not found: {library_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()
|
|
)
|
|
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({
|
|
'name': entry.name,
|
|
'path': str(entry),
|
|
'media_files': media_count
|
|
})
|
|
else:
|
|
walk(entry, depth + 1)
|
|
|
|
walk(library_root, 0)
|
|
|
|
for courses in groups.values():
|
|
courses.sort(key=lambda c: c['name'].lower())
|
|
|
|
return {'groups': groups, 'errors': errors}
|
|
|
|
|
|
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):
|
|
"""Update progress for specific lesson by path"""
|
|
progress = ProgressTracker.load_progress(course)
|
|
|
|
progress[lesson_path] = {
|
|
'completed': completed,
|
|
'progress_seconds': progress_seconds,
|
|
'last_accessed': datetime.now().isoformat()
|
|
}
|
|
|
|
# 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)
|
|
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)
|
|
|
|
# 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})
|
|
|
|
# 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)
|
|
|
|
|
|
@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():
|
|
"""
|
|
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.
|
|
"""
|
|
library_path = request.args.get('path', LIBRARY_PATH)
|
|
result = scan_course_library(library_path)
|
|
return jsonify({
|
|
'library_path': library_path,
|
|
'groups': result['groups'],
|
|
'errors': result['errors']
|
|
})
|
|
|
|
|
|
@app.route('/settings')
|
|
def settings_page():
|
|
"""Render the display-settings page."""
|
|
current = load_settings()
|
|
return render_template(
|
|
'settings.html',
|
|
settings=current,
|
|
theme_choices=sorted(SETTINGS_CHOICES['theme']),
|
|
font_family_choices=['system', 'sans', 'serif', 'monospace'],
|
|
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('/lesson/<path:lesson_path>')
|
|
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)
|
|
|
|
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)
|
|
|
|
try:
|
|
ProgressTracker.update_lesson_progress(
|
|
current_course, lesson_path, completed, progress_seconds
|
|
)
|
|
return jsonify({'success': True})
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/files/<path:filepath>')
|
|
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 = '''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>OfflineU - Select Course</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 40px; }
|
|
.container { max-width: 800px; margin: 0 auto; }
|
|
.directory { padding: 10px; border: 1px solid #ddd; margin: 5px 0; cursor: pointer; }
|
|
.directory:hover { background-color: #f0f0f0; }
|
|
.course-candidate { background-color: #e8f5e8; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>OfflineU - Course Selection</h1>
|
|
<div id="browser"></div>
|
|
<script>
|
|
// Basic directory browser implementation
|
|
function loadDirectories(path = '') {
|
|
fetch(`/browse?path=${encodeURIComponent(path)}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const browser = document.getElementById('browser');
|
|
browser.innerHTML = `
|
|
<h3>Current: ${data.current_path}</h3>
|
|
${data.parent_path ? `<div class="directory" onclick="loadDirectories('${data.parent_path}')">📁 .. (Parent)</div>` : ''}
|
|
${data.directories.map(dir => `
|
|
<div class="directory ${dir.is_course_candidate ? 'course-candidate' : ''}"
|
|
onclick="${dir.is_course_candidate ? `loadCourse('${dir.path}')` : `loadDirectories('${dir.path}')`}">
|
|
📁 ${dir.name} ${dir.media_files > 0 ? `(${dir.media_files} media files)` : ''}
|
|
</div>
|
|
`).join('')}
|
|
`;
|
|
});
|
|
}
|
|
|
|
function loadCourse(path) {
|
|
fetch('/load_course', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({course_path: path})
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
location.reload();
|
|
} else {
|
|
alert('Error: ' + data.error);
|
|
}
|
|
});
|
|
}
|
|
|
|
loadDirectories();
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>'''
|
|
|
|
# Basic course dashboard template
|
|
dashboard_template = '''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>OfflineU - {{ course.name }}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
|
.container { max-width: 1200px; margin: 0 auto; }
|
|
.module { margin: 20px 0; border: 1px solid #ddd; padding: 15px; }
|
|
.lesson { padding: 8px; margin: 5px 0; border-left: 4px solid #ddd; }
|
|
.lesson.completed { border-left-color: #4CAF50; background-color: #f8fff8; }
|
|
.lesson a { text-decoration: none; color: #333; }
|
|
.lesson:hover { background-color: #f0f0f0; }
|
|
.progress { background-color: #f0f0f0; height: 20px; border-radius: 10px; overflow: hidden; }
|
|
.progress-bar { background-color: #4CAF50; height: 100%; transition: width 0.3s; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>{{ course.name }}</h1>
|
|
<div class="progress">
|
|
<div class="progress-bar" style="width: {{ stats.completion_percentage }}%"></div>
|
|
</div>
|
|
<p>Progress: {{ stats.completed_lessons }}/{{ stats.total_lessons }} lessons ({{ stats.completion_percentage }}%)</p>
|
|
|
|
{% for module_idx, module in course.modules|enumerate %}
|
|
<div class="module">
|
|
<h2>{{ module.title }}</h2>
|
|
{% for lesson_idx, lesson in module.lessons|enumerate %}
|
|
<div class="lesson {% if lesson.completed %}completed{% endif %}">
|
|
<a href="/lesson/{{ module_idx }}/{{ lesson_idx }}">
|
|
{{ lesson.title }}
|
|
<small>({{ lesson.lesson_type }})</small>
|
|
{% if lesson.completed %}✓{% endif %}
|
|
</a>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endfor %}
|
|
|
|
<p><a href="/reset_course">Select Different Course</a></p>
|
|
</div>
|
|
</body>
|
|
</html>'''
|
|
|
|
# Basic lesson view template
|
|
lesson_template = '''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{{ lesson.title }} - {{ course.name }}</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
|
.container { max-width: 1000px; margin: 0 auto; }
|
|
video, audio { width: 100%; max-width: 800px; }
|
|
.content { margin: 20px 0; }
|
|
.navigation { margin: 20px 0; }
|
|
button { padding: 10px 20px; margin: 5px; cursor: pointer; }
|
|
.file-link { display: block; margin: 5px 0; padding: 5px; background: #f0f0f0; text-decoration: none; color: #333; }
|
|
.file-link:hover { background: #e0e0e0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>{{ lesson.title }}</h1>
|
|
<div class="navigation">
|
|
<a href="/">← Back to Course</a>
|
|
<button onclick="markCompleted()">Mark as Completed</button>
|
|
</div>
|
|
|
|
<div class="content">
|
|
{% if lesson.video_file %}
|
|
<h3>Video</h3>
|
|
<video controls preload="metadata" id="video-player">
|
|
<source src="/files/{{ lesson.video_file }}" type="video/mp4">
|
|
{% if lesson.subtitle_file %}
|
|
<track kind="subtitles" src="/files/{{ lesson.subtitle_file }}" srclang="en" label="English">
|
|
{% endif %}
|
|
Your browser does not support the video tag.
|
|
</video>
|
|
{% endif %}
|
|
|
|
{% if lesson.audio_file %}
|
|
<h3>Audio</h3>
|
|
<audio controls preload="metadata" id="audio-player">
|
|
<source src="/files/{{ lesson.audio_file }}" type="audio/mp3">
|
|
Your browser does not support the audio tag.
|
|
</audio>
|
|
{% endif %}
|
|
|
|
{% if lesson.text_files %}
|
|
<h3>Additional Resources</h3>
|
|
{% for text_file in lesson.text_files %}
|
|
<a href="/files/{{ text_file }}" class="file-link" target="_blank">
|
|
📄 {{ text_file.split('/')[-1] }}
|
|
</a>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script>
|
|
function markCompleted() {
|
|
fetch('/api/progress', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
lesson_path: '{{ lesson_path }}',
|
|
completed: true,
|
|
progress_seconds: getProgressSeconds()
|
|
})
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
alert('Lesson marked as completed!');
|
|
window.location.href = '/';
|
|
}
|
|
});
|
|
}
|
|
|
|
function getProgressSeconds() {
|
|
const video = document.getElementById('video-player');
|
|
const audio = document.getElementById('audio-player');
|
|
if (video && !video.paused) return Math.floor(video.currentTime);
|
|
if (audio && !audio.paused) return Math.floor(audio.currentTime);
|
|
return 0;
|
|
}
|
|
|
|
// Auto-save progress periodically
|
|
setInterval(() => {
|
|
const seconds = getProgressSeconds();
|
|
if (seconds > 0) {
|
|
fetch('/api/progress', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
lesson_path: '{{ lesson_path }}',
|
|
completed: false,
|
|
progress_seconds: seconds
|
|
})
|
|
});
|
|
}
|
|
}, 30000); // Save every 30 seconds
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>'''
|
|
|
|
# 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)
|