search_library_courses was walking via list_library_directory, which computes a full recursive file count (rglob) and thumbnail lookup for every course at every level - turning a search into an O(every file in every course) scan regardless of how few results actually match. Give search its own lightweight directory-only walk and defer the expensive per-course lookups until after a name match is confirmed, so cost now scales with matches found rather than total library size. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1931 lines
74 KiB
Python
1931 lines
74 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'}
|
|
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
|
|
THUMBNAIL_BASENAMES = ('cover', 'folder', 'thumbnail', 'thumb', 'poster')
|
|
|
|
# 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': '',
|
|
'playback_speed': '1', # video/audio playback rate, as a string (see SETTINGS_CHOICES)
|
|
}
|
|
|
|
# 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'},
|
|
'playback_speed': {'0.75', '1', '1.25', '1.5', '1.75', '2'},
|
|
}
|
|
|
|
# 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
|
|
|
|
|
|
def find_course_thumbnail(course_path: str) -> Optional[str]:
|
|
"""
|
|
Look for a cover image directly inside a course folder (not recursive -
|
|
this only needs to catch the common 'cover.jpg next to the sections'
|
|
layout, not go hunting through every subfolder).
|
|
"""
|
|
try:
|
|
names = {f.name.lower(): f for f in Path(course_path).iterdir() if f.is_file()}
|
|
except (PermissionError, OSError):
|
|
return None
|
|
for base in THUMBNAIL_BASENAMES:
|
|
for ext in IMAGE_EXTENSIONS:
|
|
match = names.get(f'{base}{ext}')
|
|
if match:
|
|
return str(match)
|
|
return None
|
|
|
|
|
|
_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 _rebase_prefix(value: str, old_abs: str, new_abs: str) -> str:
|
|
"""If `value` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`."""
|
|
if value == old_abs:
|
|
return new_abs
|
|
if value.startswith(old_abs + os.sep):
|
|
return new_abs + value[len(old_abs):]
|
|
return value
|
|
|
|
|
|
def rebase_library_path(old_abs: str, new_abs: str) -> None:
|
|
"""
|
|
After a directory in the library is renamed/moved on disk, rewrite any
|
|
stored absolute paths that pointed inside it (hidden_paths.json,
|
|
recent_views.json's course_path) so curation and Recently Viewed don't
|
|
silently go stale. Lesson-level progress needs no rebasing - it lives
|
|
inside the directory itself, keyed by paths relative to it, so it moves
|
|
with the rename automatically.
|
|
"""
|
|
hidden = get_hidden_paths()
|
|
rebased_hidden = [_rebase_prefix(p, old_abs, new_abs) for p in hidden]
|
|
if rebased_hidden != hidden:
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
with open(HIDDEN_PATHS_FILE, 'w') as f:
|
|
json.dump(sorted(set(rebased_hidden)), f, indent=2)
|
|
|
|
views = get_recent_views()
|
|
changed = False
|
|
for entry in views:
|
|
course_path = entry.get('course_path', '')
|
|
rebased = _rebase_prefix(course_path, old_abs, new_abs)
|
|
if rebased != course_path:
|
|
entry['course_path'] = rebased
|
|
changed = True
|
|
if changed:
|
|
try:
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
with open(RECENT_VIEWS_FILE, 'w') as f:
|
|
json.dump(views, f, indent=2)
|
|
except OSError as e:
|
|
print(f"Could not rebase recent views after rename: {e}")
|
|
|
|
|
|
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,
|
|
'has_thumbnail': find_course_thumbnail(entry_path_str) is not None
|
|
})
|
|
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
|
|
|
|
|
|
def search_library_courses(dir_path: str, query: str) -> List[Dict[str, Any]]:
|
|
"""
|
|
Recursively search the library for courses whose name contains `query`
|
|
(case-insensitive), respecting hidden paths exactly like normal browsing.
|
|
|
|
Deliberately doesn't reuse list_library_directory for the walk itself:
|
|
that function computes media_count (a full recursive file count via
|
|
rglob) and thumbnail presence for *every* course at each level, which is
|
|
fine for showing one directory's worth of courses but turns a full-
|
|
library search into an O(every file in every course) scan. Here, the
|
|
directory walk only touches directory entries (cheap - iterdir, no file
|
|
stats), and the expensive per-course lookups only run for the handful of
|
|
courses whose name actually matches.
|
|
"""
|
|
query_lower = query.lower()
|
|
hidden_set = set(get_hidden_paths())
|
|
results: List[Dict[str, Any]] = []
|
|
|
|
def walk(directory: 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):
|
|
return
|
|
|
|
for entry in entries:
|
|
if os.path.abspath(str(entry)) in hidden_set:
|
|
continue
|
|
|
|
if _looks_like_course(entry):
|
|
if query_lower in entry.name.lower():
|
|
media_count = len([
|
|
f for f in entry.rglob('*')
|
|
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
|
])
|
|
results.append({
|
|
'type': 'course',
|
|
'name': entry.name,
|
|
'path': str(entry),
|
|
'media_files': media_count,
|
|
'hidden': False,
|
|
'has_thumbnail': find_course_thumbnail(str(entry)) is not None
|
|
})
|
|
else:
|
|
walk(entry)
|
|
|
|
walk(Path(dir_path))
|
|
return results
|
|
|
|
|
|
RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json')
|
|
MAX_RECENT_VIEWS = 20
|
|
|
|
|
|
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
|
|
|
|
entry['has_thumbnail'] = find_course_thumbnail(entry.get('course_path', '')) is not None
|
|
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']
|
|
|
|
# Same for a note - this call has no opinion on it, so don't let a
|
|
# routine playback-progress save wipe one out.
|
|
if existing.get('note'):
|
|
entry['note'] = existing['note']
|
|
|
|
progress[lesson_path] = entry
|
|
|
|
# Update last accessed path
|
|
progress['last_accessed_path'] = lesson_path
|
|
|
|
ProgressTracker.save_progress(course, progress)
|
|
|
|
@staticmethod
|
|
def update_lesson_note(course: Course, lesson_path: str, note: str):
|
|
"""Save (or clear) a lesson's note, without touching its playback progress."""
|
|
progress = ProgressTracker.load_progress(course)
|
|
entry = progress.setdefault(lesson_path, {})
|
|
if note:
|
|
entry['note'] = note
|
|
else:
|
|
entry.pop('note', None)
|
|
ProgressTracker.save_progress(course, progress)
|
|
|
|
@staticmethod
|
|
def mark_all_completed(course: Course):
|
|
"""Mark every lesson in the course as completed, in one save."""
|
|
progress = ProgressTracker.load_progress(course)
|
|
|
|
def mark_node(node: DirectoryNode):
|
|
for lesson in node.lessons:
|
|
lesson_path = os.path.relpath(lesson.path, course.path).replace('\\', '/')
|
|
if lesson_path.startswith('/'):
|
|
lesson_path = lesson_path[1:]
|
|
entry = progress.setdefault(lesson_path, {})
|
|
entry['completed'] = True
|
|
entry['last_accessed'] = datetime.now().isoformat()
|
|
entry.setdefault('progress_seconds', entry.get('duration_seconds', 0))
|
|
for child in node.children.values():
|
|
mark_node(child)
|
|
|
|
mark_node(course.root_node)
|
|
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
|
|
|
|
|
|
def _split_continue_watching(all_views: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""
|
|
Split the recent-views list (already carrying percent_watched/completed,
|
|
see get_recent_views_for_display) into 'Continue Watching' - genuinely
|
|
in-progress lessons - and the remaining 'Recently Viewed' entries, each
|
|
capped at 5 for the dashboard. Reuses the same underlying history rather
|
|
than a separate library-wide progress index (see plan notes).
|
|
"""
|
|
continue_watching = [v for v in all_views if 0 < v['percent_watched'] < 100][:5]
|
|
continue_ids = {(v.get('course_path'), v.get('lesson_path')) for v in continue_watching}
|
|
recent_views = [
|
|
v for v in all_views
|
|
if (v.get('course_path'), v.get('lesson_path')) not in continue_ids
|
|
][:5]
|
|
return continue_watching, recent_views
|
|
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Main dashboard"""
|
|
global current_course
|
|
|
|
continue_watching, recent_views = _split_continue_watching(get_recent_views_for_display())
|
|
|
|
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},
|
|
continue_watching=continue_watching,
|
|
recent_views=recent_views)
|
|
|
|
# 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,
|
|
continue_watching=continue_watching,
|
|
recent_views=recent_views)
|
|
|
|
|
|
@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('/library/thumbnail')
|
|
def library_thumbnail():
|
|
"""Serve a course's cover image (see find_course_thumbnail), if it has one."""
|
|
library_root = os.path.abspath(get_library_root())
|
|
course_path = request.args.get('path', '')
|
|
target_path = os.path.abspath(course_path)
|
|
|
|
if not (target_path == library_root or target_path.startswith(library_root + os.sep)):
|
|
return '', 403
|
|
|
|
thumbnail = find_course_thumbnail(target_path)
|
|
if not thumbnail:
|
|
return '', 404
|
|
return send_file(thumbnail)
|
|
|
|
|
|
@app.route('/library/search')
|
|
def library_search():
|
|
"""Recursively search course names in the library (respects hidden paths)."""
|
|
query = request.args.get('q', '').strip()
|
|
library_root = os.path.abspath(get_library_root())
|
|
if not query:
|
|
return jsonify({'library_path': library_root, 'results': []})
|
|
|
|
results = search_library_courses(library_root, query)
|
|
return jsonify({'library_path': library_root, 'results': results})
|
|
|
|
|
|
@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('/api/rename-path', methods=['POST'])
|
|
def rename_path_api():
|
|
"""Rename a course/folder directory in the library, in place on disk."""
|
|
global current_course
|
|
|
|
data = request.json or {}
|
|
path = data.get('path', '')
|
|
new_name = (data.get('new_name') or '').strip()
|
|
|
|
if not path:
|
|
return jsonify({'error': 'path is required'}), 400
|
|
if not new_name:
|
|
return jsonify({'error': 'New name cannot be empty'}), 400
|
|
if '/' in new_name or '\\' in new_name or '\x00' in new_name or new_name in ('.', '..'):
|
|
return jsonify({'error': 'New name cannot contain path separators'}), 400
|
|
if new_name.startswith('.'):
|
|
return jsonify({'error': 'New name cannot start with a dot'}), 400
|
|
|
|
library_root = os.path.abspath(get_library_root())
|
|
old_abs = os.path.abspath(path)
|
|
if not (old_abs == library_root or old_abs.startswith(library_root + os.sep)):
|
|
return jsonify({'error': 'Path outside library root'}), 403
|
|
if old_abs == library_root:
|
|
return jsonify({'error': 'Cannot rename the library root itself'}), 400
|
|
if not os.path.isdir(old_abs):
|
|
return jsonify({'error': 'Directory not found'}), 404
|
|
|
|
new_abs = os.path.join(os.path.dirname(old_abs), new_name)
|
|
if os.path.exists(new_abs):
|
|
return jsonify({'error': f'"{new_name}" already exists here'}), 409
|
|
|
|
try:
|
|
os.rename(old_abs, new_abs)
|
|
except OSError as e:
|
|
return jsonify({'error': f'Rename failed: {e}'}), 500
|
|
|
|
rebase_library_path(old_abs, new_abs)
|
|
|
|
active_course_reset = False
|
|
if current_course is not None:
|
|
course_abs = os.path.abspath(current_course.path)
|
|
if course_abs == old_abs or course_abs.startswith(old_abs + os.sep):
|
|
current_course = None
|
|
active_course_reset = True
|
|
|
|
return jsonify({'success': True, 'new_path': new_abs, 'active_course_reset': active_course_reset})
|
|
|
|
|
|
@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/<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)
|
|
|
|
# Record for the cross-course "Recently Viewed" list on the dashboard
|
|
record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title)
|
|
|
|
# Read the note directly from the progress file rather than the Lesson
|
|
# object - apply_progress_to_tree (which populates Lesson fields) isn't
|
|
# called on this code path, only on the dashboard's tree render.
|
|
note = ProgressTracker.load_progress(current_course).get(lesson_path, {}).get('note', '')
|
|
|
|
return render_template('lesson_view.html',
|
|
course=current_course,
|
|
lesson=lesson,
|
|
lesson_path=lesson_path,
|
|
lesson_note=note,
|
|
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('/api/lesson-note', methods=['POST'])
|
|
def update_lesson_note_api():
|
|
"""API endpoint to save (or clear) a lesson's note"""
|
|
global current_course
|
|
|
|
if not current_course:
|
|
return jsonify({'error': 'No course loaded'}), 400
|
|
|
|
data = request.json or {}
|
|
lesson_path = data.get('lesson_path')
|
|
note = (data.get('note') or '').strip()
|
|
if not lesson_path:
|
|
return jsonify({'error': 'lesson_path is required'}), 400
|
|
|
|
ProgressTracker.update_lesson_note(current_course, lesson_path, note)
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@app.route('/api/course/mark-watched', methods=['POST'])
|
|
def mark_course_watched_api():
|
|
"""Mark every lesson in the currently loaded course as completed."""
|
|
global current_course
|
|
|
|
if not current_course:
|
|
return jsonify({'error': 'No course loaded'}), 400
|
|
|
|
ProgressTracker.mark_all_completed(current_course)
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@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)
|