commit 6f0fc6cb60f4139704c50e5381f7905618dc12e2 Author: Michael Sitz Date: Thu Aug 20 13:47:51 2026 -0400 Add library browser feature diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14e031a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Version control +.git +.gitignore +.github + +# Ignore Docker-related files (not needed in the image) +Dockerfile +.dockerignore + +# Ignore cache and temporary files. Logs for future use. +**/__pycache__/ +*.pyc +*.pyo +*.pyd +*.log + +# Ignore virtual environments (if using venv) +**/python_env/ +**/.venv/ +**/venv/ +**/env/ +**/ENV/ + +# OfflineU specific +.offlineu_progress.json +data/ +courses/ + +# Auto-generated templates (created at runtime if missing) +# Leave commented if you modify templates +# templates/ + +# Harmless, but reduce image size. In the future, it may make more sense to put +# everything needed by the image in e.g., /app and modify the Dockerfile to copy from +# /app as opposed to blacklisting files in the repo one-by-one +LICENSE +README.md +images/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb70a92 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# set base image (host OS) +FROM python:3.13.5-slim-bookworm + +# set the working directory in the container +WORKDIR /app + +# copy the dependencies file to the working directory +COPY requirements.txt . + +# install dependencies +RUN pip install --upgrade pip +RUN pip install -r requirements.txt + +# copy the content of the local src directory to the working directory +COPY . . + +EXPOSE 5000 + +# add healthcheck using Python standard library +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()" + +# command to run on container start +CMD [ "python", "/app/offlineu_core.py" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd5ebe7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 WhiskeyCoder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f12543c --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# OfflineU: Self-Hosted Local Course Loader & Progress Tracker + +**OfflineU** is a sleek, self-hosted web app designed to load and view your offline video, audio, text, and quiz-based training courses. Whether it's Udemy downloads, "open sourced" training archives, or personal content, OfflineU turns your course folder into a fully navigable dashboard with automatic progress tracking. + +--- + +## ✨ Features + +* 📁 **Dynamic folder parsing**: Scans and maps your course structure into a browsable tree view. +* 🎥 **Video & Audio player**: Integrated media player with resume & completion tracking. +* 📄 **Text & HTML viewer**: Supports .txt, .md, .html, .pdf, and more. +* ✅ **Lesson progress tracking**: Auto-saves your time spent and marks lessons as completed. +* ♻️ **Continue where you left off**: Resume instantly from your last-accessed lesson. +* 💾 **Local-first & private**: 100% offline. No cloud, no tracking, no nonsense. +* 🧑‍💻 **Works with any course format**: No metadata required, just structured folders. +* 🧠 **Ideal for hoarders, students, or offline learning setups** + +--- + +## 🗈️ Screenshots + +> ![image](https://github.com/WhiskeyCoder/OfflineU/blob/main/images/lesson-0-8-2025-08-04-04_58_17.png) + +--- + +## 🛠️ Installation + +### 🔁 Quick Start (Local) + +1. Clone the repo: + + ```bash + git clone https://github.com/WhiskeyCoder/OfflineU.git + cd OfflineU + ``` + +2. Install Python dependencies: + + ```bash + pip install flask + ``` + +3. Run the app: + + ```bash + python offlineu_core.py --create-templates + ``` + +4. Open your browser: + + ``` + http://127.0.0.1:5000 + ``` + +--- + +## 📂 Folder Structure Example + +```bash +MyCourse/ +├── Section 1/ +│ ├── 01 - Intro.mp4 +│ ├── 02 - Setup Guide.pdf +│ └── 03 - Quiz.html +├── Section 2/ +│ ├── 04 - Advanced Tips.mp4 +│ └── resources/ +│ └── extras.md +└── .offlineu_progress.json ← created automatically +``` + +> 🌟 File types are detected automatically — videos, audio, quizzes, and docs. + +--- + +## 📁 Supported File Types + +| Type | Extensions | +| --------- | ----------------------------------------------------------- | +| Videos | `.mp4`, `.mkv`, `.webm`, `.mov`, `.avi`, etc. | +| Audio | `.mp3`, `.wav`, `.aac`, etc. | +| Docs | `.txt`, `.md`, `.html`, `.pdf`, `.docx` | +| Subtitles | `.srt`, `.vtt` | +| Quizzes | Detected if file name contains `quiz`, `exam`, `test`, etc. | + +--- + +## ⚙️ CLI Options + +| Option | Description | +| -------------------- | ------------------------------- | +| `--host` | Set host (default: `127.0.0.1`) | +| `--port` | Set port (default: `5000`) | +| `--debug` | Enable Flask debug mode | +| `--create-templates` | Generate default HTML templates | +| `` | Load course directly at startup | + +--- + +## 🧠 Roadmap + +* [x] Base function and testing +* [ ] Multi-user profile support +* [ ] Dark/light theme switcher +* [ ] Built-in quiz interactivity +* [ ] Import/export course metadata +* [ ] Mobile app wrapper +* [ ] Self hosted Docker Deployment + +--- + +## 💬 Community + +Join the development, suggest features, or ask questions via: + +* GitHub Issues: [https://github.com/WhiskeyCoder/OfflineU/issues](https://github.com/WhiskeyCoder/OfflineU/issues) + +--- + +## 🛡️ License + +MIT License — Use freely, modify locally, share widely. + +--- + +## ✨ Author + +Built with ❤️ by [@WhiskeyCoder](https://github.com/WhiskeyCoder) +Inspired by the dream of **learning freely, offline, and without limits.** diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..68fb329 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + offlineu: + build: . + container_name: offlineu-app + ports: + - "5000:5000" + environment: + - FLASK_ENV=production + volumes: + # Course library — grouped/browsable via the new Library view + - /volume1/files/training:/app/courses + # Progress data persistence + - ./data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/docker-compose.yml.orig b/docker-compose.yml.orig new file mode 100644 index 0000000..807488d --- /dev/null +++ b/docker-compose.yml.orig @@ -0,0 +1,22 @@ +version: '3.8' + +services: + offlineu: + image: ghcr.io/skippysteve/offlineu:main + container_name: offlineu-app + ports: + - "5000:5000" + environment: + - FLASK_ENV=production + volumes: + # Mount a local directory for course data persistence + - ./courses:/app/courses + # Mount a local directory for user data/progress + - ./data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s \ No newline at end of file diff --git a/images/lesson-0-8-2025-08-04-04_58_17.png b/images/lesson-0-8-2025-08-04-04_58_17.png new file mode 100644 index 0000000..5370c9d Binary files /dev/null and b/images/lesson-0-8-2025-08-04-04_58_17.png differ diff --git a/offlineu_core.py b/offlineu_core.py new file mode 100644 index 0000000..75a95c1 --- /dev/null +++ b/offlineu_core.py @@ -0,0 +1,1038 @@ +#!/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') + + +@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('/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/') +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/') +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 + mime_type, _ = mimetypes.guess_type(full_path) + if mime_type is None: + mime_type = 'application/octet-stream' + + return send_file(full_path, mimetype=mime_type) + except Exception as e: + print(f"Error serving file: {str(e)}") + return f"Error serving file: {str(e)}", 500 + +@app.route('/health') +def healthcheck(): + """Healthcheck endpoint for Docker""" + return jsonify({"status": "healthy"}), 200 + +@app.route('/reset_course') +def reset_course(): + """Reset current course selection""" + global current_course + current_course = None + return redirect(url_for('index')) + + +def create_templates(): + """Create basic template files if they don't exist""" + templates_dir = Path('templates') + templates_dir.mkdir(exist_ok=True) + + # Basic select course template + select_template = ''' + + + OfflineU - Select Course + + + +
+

OfflineU - Course Selection

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

{{ course.name }}

+
+
+
+

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

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

{{ module.title }}

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

Select Different Course

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

{{ lesson.title }}

+ + +
+ {% if lesson.video_file %} +

Video

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

Audio

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

Additional Resources

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

OfflineU

+
+
+ + {% if course %} + + +
+
+

{{ course.name }}

+
+
+ {{ stats.completed_lessons }}/{{ stats.total_lessons }} lessons completed +
+
+ {{ "%.1f"|format(stats.completion_percentage) }}% +
+
+
+
+
+ + {% if stats.last_accessed_path %} +
+ Last Accessed: {{ stats.last_accessed_path }} + + Continue + +
+ {% endif %} +
+ +
+ {% macro render_tree_node(node, depth=0) %} +
+
+
+ 📁 + {{ node.name }} + + {{ (node.children|length + node.lessons|length) }} items + +
+ {% if node.children or node.lessons %} + + {% endif %} +
+ + {% if node.children or node.lessons %} +
+ {% for child_name, child_node in node.children.items() %} + {{ render_tree_node(child_node, depth + 1) }} + {% endfor %} + + {% for lesson in node.lessons %} + {% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %} +
+
+ + {% if lesson.lesson_type == 'video' %}🎥 + {% elif lesson.lesson_type == 'audio' %}🎵 + {% elif lesson.lesson_type == 'quiz' %}📝 + {% elif lesson.lesson_type == 'mixed' %}📦 + {% else %}📄{% endif %} + + {{ lesson.title }} +
+
+ {{ lesson.lesson_type|title }} + {% if lesson.completed %} + + {% else %} + + {% endif %} +
+
+ {% endfor %} +
+ {% endif %} +
+ {% endmacro %} + + {{ render_tree_node(course.root_node) }} +
+
+ {% else %} +
+
+

Your Courses

+

+
+
+ +
+

+ + Don't see it? Enter a path manually + +

+ +
+ +
+
+

How to Use OfflineU:

+
    +
  • Prepare your course files in a directory structure
  • +
  • Copy the full path to your course directory
  • +
  • Paste the path in the input field above
  • +
  • Click "Load Course" or press Enter
  • +
  • Start learning! Your progress will be saved automatically
  • +
+
+ +
+

Supported File Types:

+
    +
  • Videos: .mp4, .mkv, .avi, .mov, .webm
  • +
  • Audio: .mp3, .wav, .m4a, .aac
  • +
  • Documents: .txt, .md, .html, .pdf
  • +
  • Subtitles: .srt, .vtt
  • +
+
+
+
+ {% endif %} + + + + \ No newline at end of file diff --git a/templates/lesson_view.html b/templates/lesson_view.html new file mode 100644 index 0000000..29c10aa --- /dev/null +++ b/templates/lesson_view.html @@ -0,0 +1,365 @@ + + + + {{ lesson.title }} - {{ course.name }} + + + +
+

{{ lesson.title }}

+ +
+ Path: {{ lesson_path }} +
+ + + +
+ {% if lesson.video_file %} +

Video

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

Audio

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

Content

+ {% for text_file in lesson.text_files %} +
+

{{ text_file.split('/')[-1] }}

+
+
Loading content...
+
+ + 📄 Open {{ text_file.split('/')[-1] }} in new tab + +
+ {% endfor %} + {% endif %} +
+ + + + +
+ + \ No newline at end of file diff --git a/templates/select_course.html b/templates/select_course.html new file mode 100644 index 0000000..2f9a93d --- /dev/null +++ b/templates/select_course.html @@ -0,0 +1,153 @@ + + + + OfflineU - Select Course + + + +
+

OfflineU - Course Selection

+ +
+ + +
+ +
+
+
+
+ + + + +
+ +