Files
offlineu/offlineu_core.py
T
rmsitzandClaude Sonnet 5 36d9e7f89b Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails
- Auto-play next lesson on end, with a cancelable countdown and a
  Settings toggle (default on).
- Grid-view course cards show a small progress ring (checkmark at
  100%) instead of a separate bar.
- Duplicate Lesson Files: scans within each course/folder for media
  files that look like the same lesson downloaded twice, with per-file
  delete and a shared ignore list with Duplicate Courses.
- Auto-generated cover art now samples a few early candidate frames
  and keeps the largest JPEG, favoring an intro title card over a
  blank fade-in or a plain presenter frame.
- Settings -> "Regenerate Thumbnails" re-runs that logic for every
  course with an auto-generated thumbnail (never touches manual
  covers), so already-cached thumbnails can pick up the improvement.
- Add .gitignore for __pycache__/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:20:36 -04:00

5389 lines
223 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 random
import re
import sys
import argparse
import io
import itertools
import shutil
import subprocess
import threading
import time
import uuid
import zipfile
import urllib.request
import urllib.error
from pathlib import Path
from datetime import datetime, timedelta
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Any, Tuple, Iterator, Set
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'
def _load_build_version() -> str:
"""
Reads the repo-committed VERSION file - lets Settings and /health show
which version is actually running, the only way to confirm a push made
it into a rebuilt container, since the webhook does a full image
rebuild with no other visible confirmation. Deliberately a plain file
checked into the repo (updated by hand alongside each commit) rather
than baked from `git rev-parse` at Docker build time: Dockhand's build
context doesn't reliably have .git available, which silently produced
"unknown" instead of a real commit. Falls back to 'dev' if the file is
ever missing (shouldn't happen once committed, since it ships via the
same COPY . . as everything else).
"""
version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'VERSION')
try:
with open(version_file, 'r') as f:
content = f.read().strip()
if content:
return content
except OSError:
pass
return 'dev'
BUILD_VERSION = _load_build_version()
def load_changelog_entries(limit: int = 10) -> List[str]:
"""
Reads CHANGELOG.md for the Settings page's "What's New" list - one
line per commit (same timestamp + description format as VERSION),
newest first. VERSION itself is overwritten each commit and only ever
shows the current build; this accumulates that same line over time so
you can see what changed recently without digging through git log.
Falls back to an empty list if the file is missing (shouldn't happen
once committed, same reasoning as _load_build_version).
"""
changelog_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'CHANGELOG.md')
try:
with open(changelog_file, 'r') as f:
lines = [line.strip() for line in f if line.strip()]
return lines[:limit]
except OSError:
return []
# 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')
AUTO_THUMBNAIL_FILENAME = '.offlineu_thumbnail.jpg'
# 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')
# The library is typically a network-mounted (SMB) directory, where every
# iterdir()/rglob() call is a network round-trip - and the same expensive
# scans (course listing, per-course media counts, subtitle text) get
# recomputed from scratch on every single request with nothing shared
# between them. The library itself only changes on human timescales
# (someone adds a course), not per-request, so a short-TTL in-process cache
# is a safe, high-leverage fix - no external cache needed for a
# single-process personal app.
_cache_store: Dict[str, Tuple[float, Any]] = {}
CACHE_TTL_SECONDS = 300 # 5 minutes
def cache_get_or_compute(key: str, compute, ttl: float = CACHE_TTL_SECONDS):
now = time.time()
cached = _cache_store.get(key)
if cached is not None and now - cached[0] < ttl:
return cached[1]
value = compute()
_cache_store[key] = (now, value)
return value
# ---- Undo history for move/rename operations ----
#
# Only move and rename are ever recorded here - both are trivially
# reversible (move/rename the result back). Hide/show is already a
# one-click toggle with no need for "undo," and delete is permanent by
# design, so it's never in this history no matter how it's framed in the
# UI. Recorded as batches (not one entry per file) since the operations
# that populate this - Sort Unsorted apply, Bulk Rename apply, a bulk
# move - already act on several items as a single user action; undoing
# should mirror that, not require clicking "undo" N times.
_UNDO_HISTORY_MAX = 20
_undo_history: List[Dict[str, Any]] = []
def _record_undo_batch(label: str, entries: List[Dict[str, str]]) -> None:
"""Push one reversible batch - entries are {'old_path', 'new_path'}
pairs; the label is shown in the UI's Undo button. No-ops if entries
is empty (e.g. every item in a batch failed)."""
if not entries:
return
_undo_history.append({
'label': label,
'entries': entries,
'timestamp': datetime.now().isoformat(),
})
del _undo_history[:-_UNDO_HISTORY_MAX]
def invalidate_cache():
"""Clear all cached library-scan results - call after anything that changes what's on disk (rename, hide/show, library path change)."""
_cache_store.clear()
# 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)
'autoplay_next': True, # advance to the next lesson automatically when one ends
}
# 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',
# 34 palettes derived from Figma's "53 Unique Website Color Schemes"
# resource page, all converted to dark/colored backgrounds (the
# source page's own schemes were mostly light-mode marketing
# mockups) - see WEBSITE_SCHEME_CATEGORIES below for the dropdown
# grouping, and tools/generate_website_scheme_themes.py (a one-off
# content-generation script, not part of the running app) for how
# the kept ones were built: dominant colors extracted per scheme,
# mapped into this app's bg/text/accent shape with automatic
# contrast adjustment against both the background and the white
# button text, then deduplicated against each other by actual
# color distance (not eyeballed) once everything converged to dark.
'ink_wash', 'jade_pebble_morning', 'woodland', 'graphite', 'pearl', 'yacht_club',
'amber_walnut_morning', 'copper_aquamarine_dream', 'sandstone_aquamarine_serenity',
'honey_opal_sunset', 'seashell_garnet_afternoon', 'rose_quartz_evening', 'calcite',
'fireside', 'terrazzo', 'sapphire_nightfall_whisper', 'marina',
'emerald_lavender_lake', 'sage_peridot_morning', 'amethyst_dawn_haze', 'royal_glimmer',
'neptune', 'tropical_heat', 'celestial', 'festive_eve', 'freshly_squeezed',
'jelly_shoes', 'lemon_granite_morning', 'arctic_reflection', 'slate', 'autumn_luxe',
'inked', 'wraith', 'urban_nocturne',
},
'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',
'ink_wash': 'Ink Wash',
'jade_pebble_morning': 'Jade Pebble Morning',
'woodland': 'Woodland',
'graphite': 'Graphite',
'pearl': 'Pearl',
'yacht_club': 'Yacht Club',
'amber_walnut_morning': 'Amber Walnut Morning',
'copper_aquamarine_dream': 'Copper Aquamarine Dream',
'sandstone_aquamarine_serenity': 'Sandstone Aquamarine Serenity',
'honey_opal_sunset': 'Honey Opal Sunset',
'seashell_garnet_afternoon': 'Seashell Garnet Afternoon',
'rose_quartz_evening': 'Rose Quartz Evening',
'calcite': 'Calcite',
'fireside': 'Fireside',
'terrazzo': 'Terrazzo',
'sapphire_nightfall_whisper': 'Sapphire Nightfall Whisper',
'marina': 'Marina',
'emerald_lavender_lake': 'Emerald Lavender Lake',
'sage_peridot_morning': 'Sage Peridot Morning',
'amethyst_dawn_haze': 'Amethyst Dawn Haze',
'royal_glimmer': 'Royal Glimmer',
'neptune': 'Neptune',
'tropical_heat': 'Tropical Heat',
'celestial': 'Celestial',
'festive_eve': 'Festive Eve',
'freshly_squeezed': 'Freshly Squeezed',
'jelly_shoes': 'Jelly Shoes',
'lemon_granite_morning': 'Lemon Granite Morning',
'arctic_reflection': 'Arctic Reflection',
'slate': 'Slate',
'autumn_luxe': 'Autumn Luxe',
'inked': 'Inked',
'wraith': 'Wraith',
'urban_nocturne': 'Urban Nocturne',
}
# 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',
},
# --- Minimal & Neutral (from Figma's "53 Unique Website Color Schemes", converted to dark backgrounds) ---
'ink_wash': {
'bg-primary': '#202020', 'bg-secondary': '#323232', 'bg-tertiary': '#444444',
'bg-tertiary-hover': '#4b4b4b', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#494949', 'accent': '#2990d6', 'accent-hover': '#2173ab',
},
'jade_pebble_morning': {
'bg-primary': '#242d24', 'bg-secondary': '#344134', 'bg-tertiary': '#445544',
'bg-tertiary-hover': '#4b5e4b', 'text-primary': '#e5e6e5', 'text-muted': '#9ba19b',
'border-color': '#495b49', 'accent': '#9dca73', 'accent-hover': '#83bc4e',
},
'woodland': {
'bg-primary': '#323220', 'bg-secondary': '#48482e', 'bg-tertiary': '#5d5d3c',
'bg-tertiary-hover': '#676742', 'text-primary': '#e7e7e4', 'text-muted': '#a4a498',
'border-color': '#646440', 'accent': '#acc927', 'accent-hover': '#879e1f',
},
'graphite': {
'bg-primary': '#2c2c26', 'bg-secondary': '#3f3f37', 'bg-tertiary': '#525247',
'bg-tertiary-hover': '#5a5a4f', 'text-primary': '#e6e6e5', 'text-muted': '#a0a09c',
'border-color': '#57574c', 'accent': '#e07b7b', 'accent-hover': '#d65252',
},
'pearl': {
'bg-primary': '#292929', 'bg-secondary': '#3b3b3b', 'bg-tertiary': '#4d4d4d',
'bg-tertiary-hover': '#545454', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#525252', 'accent': '#d7ad84', 'accent-hover': '#ca935e',
},
'yacht_club': {
'bg-primary': '#322720', 'bg-secondary': '#48382e', 'bg-tertiary': '#5d493c',
'bg-tertiary-hover': '#675042', 'text-primary': '#e7e5e4', 'text-muted': '#a49d98',
'border-color': '#644e40', 'accent': '#c67139', 'accent-hover': '#9e5a2e',
},
# --- Warm (from Figma's "53 Unique Website Color Schemes", converted to dark backgrounds) ---
'amber_walnut_morning': {
'bg-primary': '#292929', 'bg-secondary': '#3b3b3b', 'bg-tertiary': '#4d4d4d',
'bg-tertiary-hover': '#545454', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#525252', 'accent': '#d09070', 'accent-hover': '#c3724a',
},
'copper_aquamarine_dream': {
'bg-primary': '#202c32', 'bg-secondary': '#2e3f48', 'bg-tertiary': '#3c525d',
'bg-tertiary-hover': '#425a67', 'text-primary': '#e4e6e7', 'text-muted': '#98a0a4',
'border-color': '#405864', 'accent': '#d65d29', 'accent-hover': '#ab4a21',
},
'sandstone_aquamarine_serenity': {
'bg-primary': '#322420', 'bg-secondary': '#48342e', 'bg-tertiary': '#5d443c',
'bg-tertiary-hover': '#674b42', 'text-primary': '#e7e5e4', 'text-muted': '#a49b98',
'border-color': '#644940', 'accent': '#76c9e5', 'accent-hover': '#4bb8dd',
},
'honey_opal_sunset': {
'bg-primary': '#322920', 'bg-secondary': '#483b2e', 'bg-tertiary': '#5d4d3c',
'bg-tertiary-hover': '#675442', 'text-primary': '#e7e6e4', 'text-muted': '#a49e98',
'border-color': '#645240', 'accent': '#d6b12a', 'accent-hover': '#ac8e21',
},
'seashell_garnet_afternoon': {
'bg-primary': '#203232', 'bg-secondary': '#2e4848', 'bg-tertiary': '#3c5d5d',
'bg-tertiary-hover': '#426767', 'text-primary': '#e4e7e7', 'text-muted': '#98a4a4',
'border-color': '#406464', 'accent': '#f36878', 'accent-hover': '#ef394e',
},
'rose_quartz_evening': {
'bg-primary': '#322024', 'bg-secondary': '#482e34', 'bg-tertiary': '#5d3c44',
'bg-tertiary-hover': '#67424b', 'text-primary': '#e7e4e5', 'text-muted': '#a4989b',
'border-color': '#644049', 'accent': '#f36868', 'accent-hover': '#ef3939',
},
'calcite': {
'bg-primary': '#292929', 'bg-secondary': '#3b3b3b', 'bg-tertiary': '#4d4d4d',
'bg-tertiary-hover': '#545454', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#525252', 'accent': '#f1854e', 'accent-hover': '#ed641f',
},
'fireside': {
'bg-primary': '#322420', 'bg-secondary': '#48342e', 'bg-tertiary': '#5d443c',
'bg-tertiary-hover': '#674b42', 'text-primary': '#e7e5e4', 'text-muted': '#a49b98',
'border-color': '#644940', 'accent': '#d67929', 'accent-hover': '#ab6121',
},
'terrazzo': {
'bg-primary': '#203232', 'bg-secondary': '#2e4848', 'bg-tertiary': '#3c5d5d',
'bg-tertiary-hover': '#426767', 'text-primary': '#e4e7e7', 'text-muted': '#98a4a4',
'border-color': '#406464', 'accent': '#ecad6f', 'accent-hover': '#e69342',
},
# --- Cool (from Figma's "53 Unique Website Color Schemes", converted to dark backgrounds) ---
'sapphire_nightfall_whisper': {
'bg-primary': '#202932', 'bg-secondary': '#2e3b48', 'bg-tertiary': '#3c4c5d',
'bg-tertiary-hover': '#425467', 'text-primary': '#e4e6e7', 'text-muted': '#989ea4',
'border-color': '#405264', 'accent': '#297fd6', 'accent-hover': '#2166ab',
},
'marina': {
'bg-primary': '#322720', 'bg-secondary': '#48382e', 'bg-tertiary': '#5d493c',
'bg-tertiary-hover': '#675042', 'text-primary': '#e7e5e4', 'text-muted': '#a49d98',
'border-color': '#644e40', 'accent': '#76ade5', 'accent-hover': '#4b93dd',
},
'emerald_lavender_lake': {
'bg-primary': '#203228', 'bg-secondary': '#2e4839', 'bg-tertiary': '#3c5d4a',
'bg-tertiary-hover': '#426752', 'text-primary': '#e4e7e5', 'text-muted': '#98a49d',
'border-color': '#40644f', 'accent': '#2ed174', 'accent-hover': '#25a75d',
},
'sage_peridot_morning': {
'bg-primary': '#203220', 'bg-secondary': '#2e482e', 'bg-tertiary': '#3c5d3c',
'bg-tertiary-hover': '#426742', 'text-primary': '#e4e7e4', 'text-muted': '#98a498',
'border-color': '#406440', 'accent': '#20d1af', 'accent-hover': '#19a58a',
},
'amethyst_dawn_haze': {
'bg-primary': '#242032', 'bg-secondary': '#342e48', 'bg-tertiary': '#443c5d',
'bg-tertiary-hover': '#4b4267', 'text-primary': '#e5e4e7', 'text-muted': '#9b98a4',
'border-color': '#494064', 'accent': '#ccba27', 'accent-hover': '#a1931f',
},
'royal_glimmer': {
'bg-primary': '#1f312d', 'bg-secondary': '#2d4741', 'bg-tertiary': '#3b5c56',
'bg-tertiary-hover': '#41665e', 'text-primary': '#e4e7e7', 'text-muted': '#98a4a2',
'border-color': '#3f635b', 'accent': '#29d6b4', 'accent-hover': '#21ab90',
},
'neptune': {
'bg-primary': '#203232', 'bg-secondary': '#2e4848', 'bg-tertiary': '#3c5d5d',
'bg-tertiary-hover': '#426767', 'text-primary': '#e4e7e7', 'text-muted': '#98a4a4',
'border-color': '#406464', 'accent': '#68ccf3', 'accent-hover': '#39bcef',
},
# --- Vibrant & Bold (from Figma's "53 Unique Website Color Schemes", converted to dark backgrounds) ---
'tropical_heat': {
'bg-primary': '#203232', 'bg-secondary': '#2e4848', 'bg-tertiary': '#3c5d5d',
'bg-tertiary-hover': '#426767', 'text-primary': '#e4e7e7', 'text-muted': '#98a4a4',
'border-color': '#406464', 'accent': '#d65729', 'accent-hover': '#ab4621',
},
'celestial': {
'bg-primary': '#323220', 'bg-secondary': '#48482e', 'bg-tertiary': '#5d5d3c',
'bg-tertiary-hover': '#676742', 'text-primary': '#e7e7e4', 'text-muted': '#a4a498',
'border-color': '#646440', 'accent': '#cbc026', 'accent-hover': '#a0971e',
},
'festive_eve': {
'bg-primary': '#202b32', 'bg-secondary': '#2e3e48', 'bg-tertiary': '#3c515d',
'bg-tertiary-hover': '#425a67', 'text-primary': '#e4e6e7', 'text-muted': '#98a0a4',
'border-color': '#405764', 'accent': '#8f5cf2', 'accent-hover': '#6f2dee',
},
'freshly_squeezed': {
'bg-primary': '#322d20', 'bg-secondary': '#48412e', 'bg-tertiary': '#5d553c',
'bg-tertiary-hover': '#675e42', 'text-primary': '#e7e6e4', 'text-muted': '#a4a198',
'border-color': '#645b40', 'accent': '#d6ab29', 'accent-hover': '#ab8921',
},
'jelly_shoes': {
'bg-primary': '#242032', 'bg-secondary': '#342e48', 'bg-tertiary': '#443c5d',
'bg-tertiary-hover': '#4b4267', 'text-primary': '#e5e4e7', 'text-muted': '#9b98a4',
'border-color': '#494064', 'accent': '#bc68f3', 'accent-hover': '#a739ef',
},
# --- Modern (from Figma's "53 Unique Website Color Schemes", converted to dark backgrounds) ---
'lemon_granite_morning': {
'bg-primary': '#202c32', 'bg-secondary': '#2e3f48', 'bg-tertiary': '#3c525d',
'bg-tertiary-hover': '#425a67', 'text-primary': '#e4e6e7', 'text-muted': '#98a0a4',
'border-color': '#405864', 'accent': '#c9bf27', 'accent-hover': '#9e961f',
},
'arctic_reflection': {
'bg-primary': '#202c32', 'bg-secondary': '#2e3f48', 'bg-tertiary': '#3c525d',
'bg-tertiary-hover': '#425a67', 'text-primary': '#e4e6e7', 'text-muted': '#98a0a4',
'border-color': '#405864', 'accent': '#4693b9', 'accent-hover': '#387694',
},
'slate': {
'bg-primary': '#262c26', 'bg-secondary': '#373f37', 'bg-tertiary': '#475247',
'bg-tertiary-hover': '#4f5a4f', 'text-primary': '#e5e6e5', 'text-muted': '#9ca09c',
'border-color': '#4c574c', 'accent': '#1ad236', 'accent-hover': '#14a52a',
},
'autumn_luxe': {
'bg-primary': '#303020', 'bg-secondary': '#45452e', 'bg-tertiary': '#5b5b3d',
'bg-tertiary-hover': '#646443', 'text-primary': '#e7e7e4', 'text-muted': '#a4a498',
'border-color': '#616141', 'accent': '#c08040', 'accent-hover': '#9a6633',
},
'inked': {
'bg-primary': '#171717', 'bg-secondary': '#292929', 'bg-tertiary': '#3b3b3b',
'bg-tertiary-hover': '#424242', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#404040', 'accent': '#27c9c9', 'accent-hover': '#1f9e9e',
},
'wraith': {
'bg-primary': '#1c1c12', 'bg-secondary': '#323220', 'bg-tertiary': '#48482e',
'bg-tertiary-hover': '#515134', 'text-primary': '#e7e7e4', 'text-muted': '#a4a498',
'border-color': '#4e4e32', 'accent': '#d69c29', 'accent-hover': '#ab7d21',
},
'urban_nocturne': {
'bg-primary': '#171717', 'bg-secondary': '#292929', 'bg-tertiary': '#3b3b3b',
'bg-tertiary-hover': '#424242', 'text-primary': '#e6e6e6', 'text-muted': '#9e9e9e',
'border-color': '#404040', 'accent': '#bfc927', 'accent-hover': '#969e1f',
},
}
# Groups the 53 Figma-derived theme keys above for the Settings dropdown
# (rendered as <optgroup>s) - matches that source page's own "Minimal and
# neutral / Warm / Cool / Vibrant and bold / Modern" section headings, so
# browsing the dropdown still mirrors how the original schemes were curated.
WEBSITE_SCHEME_CATEGORIES = {
'Minimal & Neutral': ['ink_wash', 'jade_pebble_morning', 'woodland', 'graphite', 'pearl', 'yacht_club'],
'Warm': ['amber_walnut_morning', 'copper_aquamarine_dream', 'sandstone_aquamarine_serenity', 'honey_opal_sunset', 'seashell_garnet_afternoon', 'rose_quartz_evening', 'calcite', 'fireside', 'terrazzo'],
'Cool': ['sapphire_nightfall_whisper', 'marina', 'emerald_lavender_lake', 'sage_peridot_morning', 'amethyst_dawn_haze', 'royal_glimmer', 'neptune'],
'Vibrant & Bold': ['tropical_heat', 'celestial', 'festive_eve', 'freshly_squeezed', 'jelly_shoes'],
'Modern': ['lemon_granite_morning', 'arctic_reflection', 'slate', 'autumn_luxe', 'inked', 'wraith', 'urban_nocturne'],
}
# 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 == 'autoplay_next' and 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 == 'autoplay_next' and isinstance(value, bool):
current[key] = value
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
current[key] = value
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, 'w') as f:
json.dump(current, f, indent=2)
if 'library_path' in new_settings:
invalidate_cache() # the library root itself changed
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
# duration_seconds comes from actual playback once a lesson's been
# watched, falling back to the ffprobe-derived cache otherwise (see
# ProgressTracker.apply_progress_to_tree) - so "remaining time"
# reflects the whole course, not just what's been played so far.
total_duration_seconds = 0
watched_seconds = 0
def count_lessons_recursive(n: DirectoryNode):
nonlocal total_lessons, completed_lessons, total_duration_seconds, watched_seconds
# Count lessons in this node
for lesson in n.lessons:
total_lessons += 1
if lesson.completed:
completed_lessons += 1
if lesson.duration_seconds:
total_duration_seconds += lesson.duration_seconds
watched_seconds += (
lesson.duration_seconds if lesson.completed
else min(lesson.progress_seconds, lesson.duration_seconds)
)
# 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),
'total_duration_seconds': total_duration_seconds,
'remaining_seconds': max(0, total_duration_seconds - watched_seconds)
}
# Exposed to templates so a section header can show "X/Y watched" for any
# DirectoryNode without a separate per-node data-loading pass - the course
# tree is walked once per render regardless, and this method already
# recurses on whatever node it's given.
app.jinja_env.globals['section_stats'] = DynamicCourseParser._calculate_completion_stats
def get_course_tree(course_path: str) -> Course:
"""
Cached DynamicCourseParser.scan_directory() - safe to cache the tree
*structure* this way because progress (watched/completed) is never
baked into it at scan time: ProgressTracker.apply_progress_to_tree()
always re-reads the live progress file and overwrites the Lesson
fields fresh on every render, same as before this cache existed.
"""
return cache_get_or_compute(f'course_tree:{course_path}', lambda: DynamicCourseParser.scan_directory(course_path))
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]:
"""
A course's cover image: a manually-placed one directly inside the
folder (cover/folder/thumbnail/thumb/poster.*, checked non-recursively
- just the common 'cover.jpg next to the sections' layout, not every
subfolder) if there is one, otherwise an auto-generated frame grabbed
from the course's first video. Almost none of this library's courses
ship with real cover art, so without the fallback the grid view would
be mostly bare initials.
"""
course_dir = Path(course_path)
try:
names = {f.name.lower(): f for f in course_dir.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)
auto_thumb = names.get(AUTO_THUMBNAIL_FILENAME)
if auto_thumb:
return str(auto_thumb)
# Generating (or determining there's nothing to generate from) is the
# one part of this that isn't a cheap directory listing - cache the
# outcome briefly so repeat image requests for a doc-only course don't
# re-walk its files on every load.
return cache_get_or_compute(
f'auto_thumbnail:{course_dir}',
lambda: _generate_course_thumbnail(course_dir)
)
def _generate_course_thumbnail(course_dir: Path) -> Optional[str]:
"""
Grab a frame from the course's first video as stand-in cover art, via
ffmpeg, cached to .offlineu_thumbnail.jpg so this only ever runs once
per course - the next call finds that file directly through the fast
path above instead of hitting this function again. Returns None
(nothing cached to disk) if the course has no video at all, so a video
added later is still picked up on the next request past the short
in-memory cache above.
Course intros typically show a title card (course/lesson name, maybe
a logo) for the first several seconds before cutting to the
presenter - a single frame at a fixed offset can easily land past
that cut and grab someone mid-sentence instead. Rather than guess one
offset, this samples a handful of candidates in the first ~8 seconds
and keeps the one with the largest resulting JPEG: a title card (text,
graphics, a logo) compresses to a noticeably bigger file than a blank
fade-in or a plain talking-head frame, which is a cheap enough proxy
for "has more going on" without any real image analysis.
"""
video_files = sorted(
f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS
)
if not video_files:
return None
source = video_files[0]
duration = _probe_media_duration_seconds(source) or 0
max_offset = min(8.0, duration * 0.5) if duration else 6.0
candidate_offsets = sorted({o for o in (1.0, 2.5, 4.0, 6.0) if o <= max_offset}) or [1.0]
output_path = course_dir / AUTO_THUMBNAIL_FILENAME
best_candidate = None
best_size = -1
candidate_paths = []
try:
for i, offset in enumerate(candidate_offsets):
candidate_path = course_dir / f'.offlineu_thumbnail_candidate_{i}.jpg'
candidate_paths.append(candidate_path)
try:
result = subprocess.run(
['ffmpeg', '-y', '-ss', str(offset), '-i', str(source),
'-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '3', str(candidate_path)],
capture_output=True, timeout=20
)
except (OSError, subprocess.SubprocessError):
continue
if result.returncode != 0 or not candidate_path.exists():
continue
size = candidate_path.stat().st_size
if size > best_size:
best_size = size
best_candidate = candidate_path
if best_candidate is None:
return None
shutil.move(str(best_candidate), str(output_path))
finally:
for candidate_path in candidate_paths:
if candidate_path.exists():
try:
candidate_path.unlink()
except OSError:
pass
return str(output_path)
_SECTION_NAME_RE = re.compile(
# (?![a-z]) rather than \b after the keyword - \b won't fire between
# "Chapter" and an immediately-following "_" since underscore counts
# as a word character too (e.g. "Chapter_1-Introduction").
r'^(section|module|chapter|part|unit|lesson)(?![a-z])|^\d+[\s_.\-]', 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 its media-holding immediate subfolders all
read as chapter/section labels ("Section 1", "Module 2", "01 -
Introduction", ...) rather than course titles - covers the common
Section 1/, Section 2/... (or numbered-chapter) layout.
Requiring *every* media-holding subfolder to match, not just one, is
what keeps a publisher/category folder holding several unrelated
courses - e.g. Claude/Pluralsight.X.../*.mp4 next to
Claude/Linkedin.Learning.Y.../*.mp4 - from being mistaken for a single
course just because more than one of its subfolders happens to store
episodes directly rather than nested under their own chapter folder.
Real course titles ("Pluralsight.Docker.Deep.Dive...") don't read as
chapter labels, so this doesn't reopen that ambiguity.
"""
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 not children_with_media:
return False
return all(_SECTION_NAME_RE.match(child.name.strip()) for child in children_with_media)
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)
invalidate_cache() # hidden set affects which courses get_all_course_dirs() yields
return result
NEXT_UP_FILE = os.path.join(DATA_DIR, 'next_up.json')
def get_next_up_paths() -> List[str]:
"""Load the ordered 'Next Up' course queue - order matters here, unlike hidden_paths, so it's a list, not a set."""
try:
if os.path.exists(NEXT_UP_FILE):
with open(NEXT_UP_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 Next Up queue: {e}")
return []
def _save_next_up_paths(paths: List[str]) -> None:
os.makedirs(DATA_DIR, exist_ok=True)
with open(NEXT_UP_FILE, 'w') as f:
json.dump(paths, f, indent=2)
def set_path_queued(path: str, queued: bool) -> List[str]:
"""Add (to the end) or remove a course from the Next Up queue; returns the updated ordered list."""
paths = get_next_up_paths()
normalized = os.path.abspath(path)
if queued:
if normalized not in paths:
paths.append(normalized)
else:
paths = [p for p in paths if p != normalized]
_save_next_up_paths(paths)
return paths
def reorder_next_up(paths: List[str]) -> List[str]:
"""Replace the Next Up order wholesale - the client computes the new order (via up/down buttons) and posts it back."""
normalized = [os.path.abspath(p) for p in paths]
_save_next_up_paths(normalized)
return normalized
def get_next_up_courses() -> List[Dict[str, Any]]:
"""Next Up queue resolved to displayable course summaries, silently dropping any path no longer on disk."""
results = []
for path in get_next_up_paths():
p = Path(path)
if p.is_dir():
results.append(_course_summary(p))
return results
return result
FAVORITES_FILE = os.path.join(DATA_DIR, 'favorites.json')
def get_favorite_paths() -> List[str]:
"""Load the set of favorited course paths - order doesn't matter here (favorites always render alphabetically), unlike Next Up's queue."""
try:
if os.path.exists(FAVORITES_FILE):
with open(FAVORITES_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 favorites: {e}")
return []
def _save_favorite_paths(paths: List[str]) -> None:
os.makedirs(DATA_DIR, exist_ok=True)
with open(FAVORITES_FILE, 'w') as f:
json.dump(paths, f, indent=2)
def set_path_favorited(path: str, favorited: bool) -> List[str]:
"""Add or remove a course from favorites; returns the updated list."""
paths = get_favorite_paths()
normalized = os.path.abspath(path)
if favorited:
if normalized not in paths:
paths.append(normalized)
else:
paths = [p for p in paths if p != normalized]
_save_favorite_paths(paths)
return paths
def get_favorite_courses() -> List[Dict[str, Any]]:
"""Favorited courses resolved to displayable summaries (name order, since there's no queue order to preserve), silently dropping any path no longer on disk."""
results = []
for path in sorted(get_favorite_paths(), key=lambda p: os.path.basename(p.rstrip(os.sep)).lower()):
p = Path(path)
if p.is_dir():
results.append(_course_summary(p))
return results
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,
next_up.json, recent_views.json's course_path) so curation, the Next
Up queue, 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)
queued = get_next_up_paths()
rebased_queue = [_rebase_prefix(p, old_abs, new_abs) for p in queued]
if rebased_queue != queued:
_save_next_up_paths(rebased_queue)
favorited = get_favorite_paths()
rebased_favorites = [_rebase_prefix(p, old_abs, new_abs) for p in favorited]
if rebased_favorites != favorited:
_save_favorite_paths(rebased_favorites)
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())
favorite_set = set(get_favorite_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):
item = _course_summary(entry)
item['hidden'] = is_hidden
item['favorited'] = os.path.abspath(entry_path_str) in favorite_set
item['completion_percentage'] = _course_completion_percentage(entry, item['media_files'])
items.append(item)
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 iter_all_courses(dir_path: str) -> Iterator[Path]:
"""
Recursively yield every course directory under dir_path, respecting
hidden paths exactly like normal browsing - without touching file
contents/counts. This is the cheap directory-only walk (iterdir +
_looks_like_course) that search, "Recently Added," and library stats
all need; the expensive per-course work (media_count via rglob,
thumbnail lookup, progress-file reads) is left to each caller to do
only for the courses it actually ends up using.
"""
hidden_set = set(get_hidden_paths())
def walk(directory: Path) -> Iterator[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):
yield entry
else:
yield from walk(entry)
yield from walk(Path(dir_path))
def get_all_course_dirs() -> List[Path]:
"""
Cached, materialized iter_all_courses(get_library_root()) - every
caller (search, transcript search, Recently Added, library stats,
Notes Hub, backup export) needs the exact same recursive directory
walk over the library root, so compute it once and share it instead
of every caller re-walking the filesystem independently.
"""
return cache_get_or_compute('course_dirs', lambda: list(iter_all_courses(get_library_root())))
# ---- Unsorted intake: propose where a newly-dropped course belongs ----
#
# Workflow: new/incoming courses get dropped into a folder named Unsorted
# directly under the library root, sitting alongside the real category
# tree (e.g. IT/, IT/AWS/, Business/). Scanning proposes a destination for
# each item by keyword overlap against that *existing* tree - read fresh
# from disk on every scan, never hardcoded - so it adapts automatically to
# whatever categories actually exist. Nothing gets moved until the moves
# are explicitly applied (see /api/unsorted/apply).
UNSORTED_FOLDER_NAME = 'Unsorted'
_TOKEN_SPLIT_RE = re.compile(r'[^a-z0-9]+')
_TOKEN_STOPWORDS = {
'a', 'an', 'and', 'for', 'from', 'in', 'is', 'of', 'on', 'or', 'the', 'to', 'with',
'course', 'courses', 'training', 'class', 'complete', 'full', 'guide', 'tutorial',
'part', 'vol', 'volume', 'edition',
# Skill-level/marketing filler - describes the packaging, not the
# subject, same spirit as 'complete'/'guide'/'tutorial' above.
'advanced', 'intermediate', 'beginner', 'beginners', 'basics', 'basic',
'essentials', 'fundamentals', 'mastering', 'master', 'crash', 'level', 'levels',
# E-learning platforms/marketplaces - whichever site a course was bought
# or ripped from says nothing about its subject, and it shows up in
# almost every course name from that source.
'udemy', 'pluralsight', 'lynda', 'linkedin', 'linkedinlearning', 'coursera',
'skillshare', 'packt', 'packtpub', 'oreilly', 'edx', 'educative', 'egghead',
'frontendmasters', 'codecademy', 'udacity', 'skillsoft', 'cbtnuggets', 'cbt',
'itprotv', 'cybrary', 'tutsplus', 'masterclass', 'treehouse', 'datacamp',
# Scene/distribution-group tags and rip metadata that piggyback on
# downloaded course names - never a subject, always noise.
'bookware', 'tutsnode', 'ftu', 'ftuforum', 'freecoursesonline', 'freetutorials',
'coursedrive', 'downtem', 'glodls', 'garr', 'ridware', 'illiterate', 'zh',
'nfo', 'readnfo', 'repack', 'reup', 'reupload', 'repost', 'release', 'released',
'rip', 'x264', 'x265', 'www', 'com', 'net', 'org', 'info',
# "Freshness" marketing words - describe the release, not the subject.
'update', 'updated', 'new', 'newest', 'latest', 'final', 'retail',
# Calendar words - dates say nothing about what a course is about.
'january', 'february', 'march', 'april', 'june', 'july', 'august', 'september',
'october', 'november', 'december', 'jan', 'feb', 'mar', 'apr', 'jun', 'jul',
'aug', 'sep', 'sept', 'oct', 'nov', 'dec',
}
def _tokenize(name: str) -> Set[str]:
"""
Break a folder/course name into lowercase keyword tokens for matching.
Splits camelCase/PascalCase boundaries before the general split, since
course names are commonly dot- or dash-joined with no spaces at all
(e.g. "AWSCertifiedSolutionsArchitect" as much as "AWS.Certified...").
Keeps short tokens (unlike most tokenizers) because real category
names here are often exactly that short - "IT", "PM" - and drops pure
numbers (section/edition numbers) and common filler words instead,
which is what actually carries no signal in this context.
"""
return set(_tokenize_ordered(name))
def _tokenize_ordered(name: str) -> List[str]:
"""Same tokens as _tokenize, but as a de-duplicated list in the order
they first appear - used when a token subset needs to read back as a
sensible phrase (a suggested new folder name) rather than just being
compared as a set."""
spaced = re.sub(r'(?<=[a-z0-9])(?=[A-Z])', ' ', name)
raw = _TOKEN_SPLIT_RE.split(spaced.lower())
seen: Set[str] = set()
ordered = []
for t in raw:
if len(t) >= 2 and not t.isdigit() and t not in _TOKEN_STOPWORDS and t not in seen:
seen.add(t)
ordered.append(t)
return ordered
def _is_unrecognized_leaf_item(directory: Path) -> bool:
"""
A folder with files directly inside but no subfolders at all -
virtually always a single downloaded item (an ebook, an audiobook in a
format this app doesn't play, or some other misc file drop) rather
than an organizational category, even when _looks_like_course's
video/audio check doesn't recognize it as a course (e.g. an EPUB-only
folder has no media files at all, so _has_direct_media never fires).
Scoped to _build_category_index only - not a general "is this a
course" replacement, since this app still can't do anything useful
with the file itself, so it'd be wrong to surface it as a course
elsewhere (Library browsing, stats, search, ...).
"""
try:
entries = list(directory.iterdir())
except (PermissionError, OSError):
return False
has_subdir = any(e.is_dir() and not e.name.startswith('.') for e in entries)
if has_subdir:
return False
return any(e.is_file() and not e.name.startswith('.') for e in entries)
def _subtree_has_any_media(directory: Path, max_depth: int = 8) -> bool:
"""Whether `directory` or anything under it (bounded depth, so a huge
non-course tree can't make a scan slow) contains at least one
recognized video/audio file anywhere. False for a folder that's really
a course's bundled extras - downloaded source code, a Python
virtualenv/dependency dump, a project's asset tree, etc. - which can
have plenty of subfolders (so _is_unrecognized_leaf_item doesn't catch
it) but no lecture content anywhere in them."""
try:
entries = list(directory.iterdir())
except (PermissionError, OSError):
return False
for e in entries:
if e.is_file() and not e.name.startswith('.') and e.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
return True
if max_depth <= 0:
return False
for e in entries:
if e.is_dir() and not e.name.startswith('.') and _subtree_has_any_media(e, max_depth - 1):
return True
return False
def _is_media_free_subtree(directory: Path) -> bool:
"""A folder that has subfolders (so it isn't just an intentionally
empty, freshly-created category) but no video/audio anywhere beneath
it - excluded from the destination picker for the same reason as
_is_unrecognized_leaf_item, just for a deeper non-course tree instead
of a single stray file."""
try:
has_subdir = any(e.is_dir() and not e.name.startswith('.') for e in directory.iterdir())
except (PermissionError, OSError):
return False
if not has_subdir:
return False
return not _subtree_has_any_media(directory)
def _is_course_wrapper(directory: Path, min_similarity: float = 0.5) -> bool:
"""
A release-bundle folder whose name is really just a course title with
extra decoration (platform name, distribution tag, ...), wrapping a
single actual course folder one level down alongside unrelated junk
at the same level - e.g. a "Udemy...BOOKWARE-XYZ" folder containing
both "Course Title Here/Chapter 1/video.mp4" *and* a pile of bundled
source code/empty leftover folders from extraction as siblings.
_looks_like_course only checks one level of nesting, so this reads as
a bare category with a course buried inside it - and unlike
_is_media_free_subtree, the junk siblings here can be perfectly
ordinary empty folders (indistinguishable from an intentionally empty,
freshly-created category), so that check alone doesn't catch it.
Detected by token similarity (the same Jaccard measure
_find_duplicate_courses uses) between the directory's own name and its
one course child's name: high overlap means they're naming the same
thing, not a deliberate parent/child relationship like "IT/AWS"
holding an "AWS Certified..." course, whose names barely overlap.
"""
try:
children = [c for c in directory.iterdir() if c.is_dir() and not c.name.startswith('.')]
except (PermissionError, OSError):
return False
course_children = [c for c in children if _looks_like_course(c)]
if len(course_children) != 1:
return False
dir_tokens = _tokenize(directory.name)
course_tokens = _tokenize(course_children[0].name)
union_tokens = dir_tokens | course_tokens
if not union_tokens:
return False
similarity = len(dir_tokens & course_tokens) / len(union_tokens)
return similarity >= min_similarity
def _build_category_index() -> List[Dict[str, Any]]:
"""
Every category/subcategory folder currently in the library (e.g. IT,
IT/AWS) as a possible destination, each with keyword tokens split into
two tiers of trust:
- 'path_tokens': from the folder's own path components - a deliberate
name the user chose, so a match here is always taken at face value.
- 'bonus_tokens': from the titles of courses already filed directly
under it - lets a folder match on subjects its name alone doesn't
mention (an "IT/AWS" folder full of courses titled "...Solutions
Architect..." now also matches on "solutions" and "architect"),
without hardcoding any subject list. Since these come from
whatever the courses happen to be named, they're incidental rather
than deliberate - _propose_destination downweights them by how
many *different* categories share the same bonus word, so a word
that turns out to be common clutter (recurring across many
categories' course titles) can't outweigh a specific match
elsewhere just by sharing more of it.
Stops recursing into a folder once it reads as a course itself
(_looks_like_course, the same rule the Library browser uses), an
unrecognized leaf item (_is_unrecognized_leaf_item - an ebook/
audiobook/misc file that isn't video or audio), a subtree with no
video/audio anywhere in it at all (_is_media_free_subtree - a course's
bundled source code/project files, a Python virtualenv, etc.), or a
release-bundle wrapper around a single course one level down
(_is_course_wrapper - the video content sits under an extra "course
display name" folder, one level deeper than _looks_like_course checks,
alongside unrelated - possibly empty - junk siblings), so individual
courses and their non-lecture contents never show up as if they were
categories to file things under. The Unsorted folder itself is
excluded - it's the source, never a valid destination.
This walk visits every directory in the library and, over a
NAS-mounted (SMB) library, each of those is a network round-trip -
the same reasoning behind get_all_course_dirs()'s cache, so this
result is cached the same way (same 5-minute TTL, same
invalidate_cache() call sites already busting it - no separate
invalidation needed): otherwise this ran fresh on every File
Management page load and Sort Unsorted scan, neither of which is a
manual "this may take a moment" action like Duplicate Courses or
Storage Usage, so the cost was invisible until it was already slow.
"""
def compute() -> List[Dict[str, Any]]:
library_root = Path(get_library_root())
try:
unsorted_root = (library_root / UNSORTED_FOLDER_NAME).resolve()
except OSError:
unsorted_root = None
categories: List[Dict[str, Any]] = []
def walk(directory: Path, path_parts: List[str]):
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:
try:
if unsorted_root is not None and entry.resolve() == unsorted_root:
continue
except OSError:
pass
if (_looks_like_course(entry) or _is_unrecognized_leaf_item(entry)
or _is_media_free_subtree(entry) or _is_course_wrapper(entry)):
continue
new_parts = path_parts + [entry.name]
path_tokens: Set[str] = set()
for part in new_parts:
path_tokens |= _tokenize(part)
bonus_tokens: Set[str] = set()
try:
for child in entry.iterdir():
if child.is_dir() and not child.name.startswith('.') and _looks_like_course(child):
bonus_tokens |= _tokenize(child.name)
except (PermissionError, OSError):
pass
bonus_tokens -= path_tokens
categories.append({
'path': str(entry),
'relative': '/'.join(new_parts),
'depth': len(new_parts),
'path_tokens': path_tokens,
'bonus_tokens': bonus_tokens,
})
walk(entry, new_parts)
walk(library_root, [])
return categories
return cache_get_or_compute('category_index', compute)
def _bonus_token_frequency(categories: List[Dict[str, Any]]) -> Dict[str, int]:
"""How many distinct categories each bonus token shows up in, across
the whole library - used to tell a word that's actually specific to one
place ("cloudformation") from one that's just common clutter recurring
in course titles everywhere ("advanced", a creator's name, etc.), so
the latter can be downweighted instead of needing to be individually
named as a stopword ahead of time."""
freq: Dict[str, int] = {}
for cat in categories:
for token in cat['bonus_tokens']:
freq[token] = freq.get(token, 0) + 1
return freq
def _list_unsorted_items() -> List[Path]:
"""Direct children of the Unsorted folder - each one is a course
waiting to be filed into the real category tree."""
unsorted_root = Path(get_library_root()) / UNSORTED_FOLDER_NAME
if not unsorted_root.is_dir():
return []
return sorted(
(p for p in unsorted_root.iterdir() if p.is_dir() and not p.name.startswith('.')),
key=lambda p: p.name.lower()
)
def _propose_destination(item_name: str, categories: List[Dict[str, Any]],
bonus_df: Dict[str, int]) -> Dict[str, Any]:
"""
Best-effort destination for one Unsorted item, by keyword overlap
against the existing category tree. Three outcomes:
- 'existing': a real folder already matches confidently - move there.
- 'new_folder': a broader category matches, but nothing that specific
already exists under it - propose creating a new subfolder there,
named from whichever of the item's own keywords the category
didn't already cover.
- 'manual': nothing matches with any confidence - needs a human.
Categories are ranked by evidence quality, not raw shared-word count -
a match on a *path* token (the folder's own deliberate name, e.g. the
"AWS" in IT/AWS) always outranks any number of *bonus* tokens (words
only borrowed from a sibling course's title - see
_build_category_index), since a folder's own name is deliberate and a
sibling course's title is incidental. Among bonus-only matches, the
single least-diluted word wins rather than the total - a bonus word
shared by only one category counts fully, one recurring across N
categories counts for 1/N - so several incidental, recurring words (a
prolific creator's name, a repeated marketing phrase, etc. showing up
across many categories' course titles) can't outvote one genuinely
specific word just by there being more of them, and don't need to be
individually named as stopwords ahead of time to be discounted.
"Confident" then means the best match is either a path-token hit or an
essentially-unique bonus word (not diluted across other categories),
AND it's either at a fairly specific existing folder (2+ levels deep,
e.g. IT/AWS) or corroborated by a second shared word - one weak,
common word alone isn't enough to drop something straight into a bare
top-level category ("IT"); that's exactly the case a new subfolder
proposal is for.
"""
item_tokens_ordered = _tokenize_ordered(item_name)
item_tokens = set(item_tokens_ordered)
if not item_tokens:
return {'type': 'manual', 'destination': None,
'reason': "Couldn't extract any usable keywords from this name."}
scored = []
for cat in categories:
overlap = item_tokens & (cat['path_tokens'] | cat['bonus_tokens'])
if not overlap:
continue
path_hits = overlap & cat['path_tokens']
bonus_overlap = overlap - cat['path_tokens']
bonus_best = max((1.0 / bonus_df.get(t, 1) for t in bonus_overlap), default=0.0)
scored.append({
'cat': cat, 'overlap': overlap,
'path_hits': len(path_hits), 'bonus_best': bonus_best, 'count': len(overlap),
})
if not scored:
return {'type': 'manual', 'destination': None,
'reason': "No existing folder shares any keywords with this name."}
scored.sort(key=lambda s: (s['path_hits'], s['bonus_best'], s['count'], s['cat']['depth']), reverse=True)
best = scored[0]
best_cat = best['cat']
overlap_words = ', '.join(w for w in item_tokens_ordered if w in best['overlap'])
best_all_tokens = best_cat['path_tokens'] | best_cat['bonus_tokens']
is_strong_match = best['path_hits'] >= 1 or best['bonus_best'] >= 1.0
if is_strong_match and (best_cat['depth'] >= 2 or best['count'] >= 2):
return {
'type': 'existing',
'destination': best_cat['relative'],
'reason': f'Matches existing folder "{best_cat["relative"]}" on: {overlap_words}',
}
leftover = item_tokens - best_all_tokens
if leftover:
# preserve the item's own word order rather than the set's
# arbitrary one, so "Docker Compose Basics" suggests "Compose
# Basics" (dropping the already-covered "docker"), not a
# scrambled "Basics Compose"
leftover_ordered = [w for w in item_tokens_ordered if w in leftover]
new_name = ' '.join(w.upper() if len(w) <= 3 else w.capitalize() for w in leftover_ordered)
return {
'type': 'new_folder',
'destination': f"{best_cat['relative']}/{new_name}",
'reason': f'"{best_cat["relative"]}" matches on {overlap_words}, but no existing subfolder covers this - suggesting a new one.',
}
return {
'type': 'existing',
'destination': best_cat['relative'],
'reason': f'Matches existing folder "{best_cat["relative"]}" on: {overlap_words}',
}
IGNORED_DUPLICATES_FILE = os.path.join(DATA_DIR, 'ignored_duplicates.json')
def get_ignored_duplicate_pairs() -> Set[Tuple[str, str]]:
"""Pairs of course paths the user has confirmed are NOT duplicates of
each other, despite matching the tokenizer - excluded from future
duplicate scans (see _find_duplicate_courses) so a wrong call doesn't
keep resurfacing. Each pair is stored path-order-independent."""
try:
if os.path.exists(IGNORED_DUPLICATES_FILE):
with open(IGNORED_DUPLICATES_FILE, 'r') as f:
data = json.load(f)
if isinstance(data, list):
return {tuple(sorted(pair)) for pair in data if isinstance(pair, list) and len(pair) == 2}
except (json.JSONDecodeError, OSError) as e:
print(f"Could not load ignored duplicates: {e}")
return set()
def _save_ignored_duplicate_pairs(pairs: Set[Tuple[str, str]]) -> None:
os.makedirs(DATA_DIR, exist_ok=True)
with open(IGNORED_DUPLICATES_FILE, 'w') as f:
json.dump([list(p) for p in sorted(pairs)], f, indent=2)
def add_ignored_duplicate_pairs(pairs: List[Tuple[str, str]]) -> None:
"""Mark one or more course-path pairs as confirmed-not-duplicates."""
existing = get_ignored_duplicate_pairs()
existing |= {tuple(sorted(p)) for p in pairs}
_save_ignored_duplicate_pairs(existing)
def remove_ignored_duplicate_pair(path_a: str, path_b: str) -> None:
"""Undo add_ignored_duplicate_pairs for one pair - lets a wrong ignore
call be reversed, matching the reversible-curation pattern hidden
paths/Next Up already follow elsewhere in the app."""
existing = get_ignored_duplicate_pairs()
existing.discard(tuple(sorted((path_a, path_b))))
_save_ignored_duplicate_pairs(existing)
def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]:
"""
Group courses across the whole library (Unsorted included, so a
just-dropped item that duplicates something already filed gets caught
too) whose names look like the same thing filed twice - a re-download
that landed in a different category, or a course that's both sorted
and still sitting in Unsorted. Reuses Sort Unsorted's own
tokenizer/stopword list, so platform names, release-group tags, and
dates are already ignored - "AWS Certified Solutions Architect" and
"AWS.Certified.Solutions.Architect.2024.BOOKWARE-XYZ" tokenize down to
the same core words despite looking nothing alike as strings.
Pairwise Jaccard similarity (shared tokens / all tokens) at or above
min_similarity gets union-found into groups, so if A matches B and B
matches C, all three land in one group instead of two overlapping
pairs - except any pair the user has explicitly marked as not-actually-
duplicates (get_ignored_duplicate_pairs), which never unions regardless
of how similar the names look. Nothing here touches disk - it's
read-only, for the user to review and hide/delete/rename/move manually.
"""
ignored_pairs = get_ignored_duplicate_pairs()
entries = []
for course in get_all_course_dirs():
tokens = _tokenize(course.name)
if tokens:
entries.append({'path': str(course), 'name': course.name, 'tokens': tokens})
n = len(entries)
parent = list(range(n))
def find(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
def union(i: int, j: int) -> None:
ri, rj = find(i), find(j)
if ri != rj:
parent[ri] = rj
pair_similarity: Dict[Tuple[int, int], float] = {}
for i in range(n):
for j in range(i + 1, n):
if tuple(sorted((entries[i]['path'], entries[j]['path']))) in ignored_pairs:
continue
a, b = entries[i]['tokens'], entries[j]['tokens']
union_tokens = a | b
if not union_tokens:
continue
similarity = len(a & b) / len(union_tokens)
if similarity >= min_similarity:
pair_similarity[(i, j)] = similarity
union(i, j)
clusters: Dict[int, List[int]] = {}
for i in range(n):
clusters.setdefault(find(i), []).append(i)
groups = []
for members in clusters.values():
if len(members) < 2:
continue
best_similarity = max(
(sim for (i, j), sim in pair_similarity.items() if i in members and j in members),
default=0.0
)
groups.append({
'courses': sorted(
({'path': entries[i]['path'], 'name': entries[i]['name']} for i in members),
key=lambda c: c['name'].lower()
),
'similarity': round(best_similarity, 2),
})
groups.sort(key=lambda g: g['similarity'], reverse=True)
return groups
def _find_duplicate_lesson_files(min_similarity: float = 0.75) -> List[Dict[str, Any]]:
"""
Within each course, media files sitting in the same folder whose
names look like the same lesson downloaded twice - e.g. "03 Setting
Up Your Environment.mp4" and "03 Setting Up Your Environment (1).mp4"
left behind by a re-download that landed in the same section. Scoped
to siblings in the same folder, unlike Duplicate Courses' whole-library
comparison: two genuinely different lessons can share a lot of
vocabulary ("Part 1"/"Part 2" of a topic) without being duplicates, so
same-folder pairing plus a higher similarity threshold keeps this
conservative. Shares Duplicate Courses' ignore list (get_ignored_
duplicate_pairs) - a pair is just a pair of paths there, whether
they're course folders or files. Read-only.
"""
ignored_pairs = get_ignored_duplicate_pairs()
groups = []
for course_dir in get_all_course_dirs():
by_folder: Dict[Path, List[Path]] = {}
for f in course_dir.rglob('*'):
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
by_folder.setdefault(f.parent, []).append(f)
for files in by_folder.values():
if len(files) < 2:
continue
entries = [{'path': f, 'tokens': _tokenize(f.stem)} for f in files]
n = len(entries)
for i in range(n):
for j in range(i + 1, n):
path_i, path_j = str(entries[i]['path']), str(entries[j]['path'])
if tuple(sorted((path_i, path_j))) in ignored_pairs:
continue
a, b = entries[i]['tokens'], entries[j]['tokens']
union_tokens = a | b
if not union_tokens:
continue
similarity = len(a & b) / len(union_tokens)
if similarity < min_similarity:
continue
try:
size_i = entries[i]['path'].stat().st_size
size_j = entries[j]['path'].stat().st_size
except OSError:
continue
groups.append({
'course_name': course_dir.name,
'course_path': str(course_dir),
'files': sorted([
{'path': path_i, 'name': entries[i]['path'].name, 'bytes': size_i, 'human': _format_bytes(size_i)},
{'path': path_j, 'name': entries[j]['path'].name, 'bytes': size_j, 'human': _format_bytes(size_j)},
], key=lambda f: f['name'].lower()),
'similarity': round(similarity, 2),
})
groups.sort(key=lambda g: g['similarity'], reverse=True)
return groups
def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
"""
Entries in hidden_paths.json / next_up.json / recent_views.json that
point at a path no longer on disk - typically because a course was
renamed, moved, or deleted directly on the NAS rather than through the
app (a rename/move made through the app already rebases these, see
rebase_library_path). Read-only; nothing is removed until
/api/library/stale-references/clean is called with the exact items
reviewed here.
"""
stale: Dict[str, List[Dict[str, str]]] = {'hidden': [], 'next_up': [], 'recent_views': [], 'favorites': []}
for path in get_hidden_paths():
if not os.path.isdir(path):
stale['hidden'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
for path in get_next_up_paths():
if not os.path.isdir(path):
stale['next_up'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
for path in get_favorite_paths():
if not os.path.isdir(path):
stale['favorites'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
seen: Set[str] = set()
for entry in get_recent_views():
path = entry.get('course_path', '')
if path and path not in seen and not os.path.isdir(path):
seen.add(path)
name = entry.get('course_name') or os.path.basename(path.rstrip(os.sep)) or path
stale['recent_views'].append({'path': path, 'name': name})
return stale
def _probe_media_duration_seconds(file_path: Path) -> Optional[float]:
"""Read a single media file's duration via ffprobe. None if ffprobe is
missing, the file isn't readable, or the output can't be parsed."""
try:
result = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', str(file_path)],
capture_output=True, text=True, timeout=15
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
try:
return float(result.stdout.strip())
except ValueError:
return None
DURATION_CACHE_FILENAME = '.offlineu_duration_cache.json'
def _ensure_media_durations_cached(course_dir: Path, media_files: List[Path]) -> Dict[str, Any]:
"""
Make sure every given media file has a known duration in the
persistent per-course cache file, keyed by (relative path, size,
mtime) - ffprobe only ever runs for a file that's new or has changed
since it was last cached, so a container restart or the library scan's
5-minute in-memory cache expiring never re-probes an unchanged file.
Returns the up-to-date {relative_path: {size, mtime, duration_seconds}}
cache, so callers needing a single lesson's duration (not just the
course total) don't have to re-read the cache file themselves.
"""
cache_file = course_dir / DURATION_CACHE_FILENAME
try:
with open(cache_file, 'r') as f:
cache = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
cache = {}
changed = False
seen_keys = set()
for media_path in media_files:
try:
rel_key = str(media_path.relative_to(course_dir))
stat = media_path.stat()
except OSError:
continue
seen_keys.add(rel_key)
cached = cache.get(rel_key)
if not (cached and cached.get('size') == stat.st_size and cached.get('mtime') == stat.st_mtime):
duration = _probe_media_duration_seconds(media_path)
cache[rel_key] = {'size': stat.st_size, 'mtime': stat.st_mtime, 'duration_seconds': duration}
changed = True
# Drop entries for files that no longer exist, so a renamed/deleted
# course's cache doesn't grow stale entries forever.
stale_keys = set(cache.keys()) - seen_keys
if stale_keys:
for key in stale_keys:
del cache[key]
changed = True
if changed:
try:
with open(cache_file, 'w') as f:
json.dump(cache, f, indent=2)
except OSError as e:
print(f"Could not save duration cache: {e}")
return cache
def _course_total_duration_seconds(course_dir: Path, media_files: List[Path]) -> Optional[float]:
"""Total runtime across a course's video/audio files, for the library browser's "how long is this" display."""
if not media_files:
return None
cache = _ensure_media_durations_cached(course_dir, media_files)
total = 0.0
have_any = False
for media_path in media_files:
try:
rel_key = str(media_path.relative_to(course_dir))
except ValueError:
continue
duration = cache.get(rel_key, {}).get('duration_seconds')
if duration:
total += duration
have_any = True
return total if have_any else None
def _cached_course_duration_seconds(course_dir: Path) -> float:
"""
Sum of whatever's already in a course's persistent duration cache file,
without walking its files or ever touching ffprobe - for the
library-wide "time remaining" stat, which needs to stay cheap enough to
compute on every dashboard load (it runs inside the same per-course
loop as _scan_library_activity). A course that's never been viewed,
searched, or prewarmed yet just contributes 0 here; run "Precompute
Lengths & Cover Art" in Settings for a complete total.
"""
try:
with open(course_dir / DURATION_CACHE_FILENAME, 'r') as f:
cache = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return 0.0
return sum(entry.get('duration_seconds') or 0 for entry in cache.values())
def _course_summary(course_dir: Path) -> Dict[str, Any]:
"""Build the {type, name, path, media_files, hidden, has_thumbnail,
duration_display} shape shared by the Library browser, search results,
and Recently Added."""
def compute():
media_files = [
f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
]
total_duration = _course_total_duration_seconds(course_dir, media_files)
return {
'type': 'course',
'name': course_dir.name,
'path': str(course_dir),
'media_files': len(media_files),
'hidden': False,
'has_thumbnail': find_course_thumbnail(str(course_dir)) is not None,
# under a minute isn't a meaningful "how long will this take" signal
'duration_display': format_duration(total_duration) if total_duration and total_duration >= 60 else None,
}
return dict(cache_get_or_compute(f'course_summary:{course_dir}', compute))
def _course_completion_percentage(course_dir: Path, media_files: int) -> Optional[float]:
"""
Cheap, uncached completion percentage for a single course card - reads
only that course's own small progress JSON directly (same pattern as
_scan_library_activity), never the cached _course_summary result, so a
lesson finished a second ago shows up immediately instead of waiting out
the library-scan cache TTL. None if nothing's completed yet, so
untouched courses don't show a noisy "0%" badge.
"""
if media_files <= 0:
return None
try:
with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
completed = sum(
1 for key, entry in progress.items()
if key != 'last_accessed_path' and isinstance(entry, dict) and entry.get('completed')
)
if completed == 0:
return None
return round(min(100.0, 100 * completed / media_files), 1)
def search_library_courses(query: str) -> List[Dict[str, Any]]:
"""
Search the library for courses whose name contains `query`
(case-insensitive). The expensive per-course lookups (media_count,
thumbnail) only run for courses whose name actually matches - see
get_all_course_dirs.
"""
query_lower = query.lower()
favorite_set = set(get_favorite_paths())
results = []
for course in get_all_course_dirs():
if query_lower not in course.name.lower():
continue
item = _course_summary(course)
item['favorited'] = os.path.abspath(str(course)) in favorite_set
item['completion_percentage'] = _course_completion_percentage(course, item['media_files'])
results.append(item)
return results
def _extract_subtitle_snippet(text: str, query_lower: str, context_chars: int = 80) -> str:
"""A short excerpt around the first match, with subtitle sequence-number/timestamp lines stripped."""
lower = text.lower()
idx = lower.find(query_lower)
if idx == -1:
return ''
start = max(0, idx - context_chars)
end = min(len(text), idx + len(query_lower) + context_chars)
excerpt_lines = text[start:end].splitlines()
cleaned = [
line.strip() for line in excerpt_lines
if line.strip() and '-->' not in line and not line.strip().isdigit()
and line.strip().upper() != 'WEBVTT'
]
snippet = ' '.join(cleaned)
return ('…' if start > 0 else '') + snippet + ('…' if end < len(text) else '')
def _course_subtitle_index(course_dir: Path) -> List[Tuple[Path, str, str]]:
"""
Cached (subtitle_file, text, text_lower) triples for one course -
reading and lowercasing every subtitle file is identical work across
every search query, so do it once per cache window and just search the
already-read text in memory for each new query instead of re-reading
every file from disk (typically a network-mounted NAS) every time.
"""
def compute():
pairs = []
for f in course_dir.rglob('*'):
if f.is_file() and f.suffix.lower() in SUBTITLE_EXTENSIONS:
try:
text = f.read_text(encoding='utf-8', errors='ignore')
except OSError:
continue
pairs.append((f, text, text.lower()))
return pairs
return cache_get_or_compute(f'subtitle_index:{course_dir}', compute)
def search_transcripts(query: str, limit: int = 30) -> List[Dict[str, Any]]:
"""
Search inside lesson subtitle files (.srt/.vtt/etc.) for `query`,
case-insensitive - see _course_subtitle_index for how the expensive
per-course file reads are cached and shared across queries.
Subtitle files aren't wired into the Lesson tree today (see
DynamicCourseParser._create_lesson_from_file - a subtitle file never
becomes part of a Lesson, so lesson.subtitle_file is always empty).
Rather than depend on that, this matches a subtitle file to its lesson
by finding a same-named video/audio file next to it, which is how
these files are conventionally paired on disk regardless.
"""
query_lower = query.lower()
results: List[Dict[str, Any]] = []
for course_dir in get_all_course_dirs():
for subtitle_file, text, text_lower in _course_subtitle_index(course_dir):
if len(results) >= limit:
return results
if query_lower not in text_lower:
continue
media_match = next(
(f for f in subtitle_file.parent.iterdir()
if f.is_file() and f.stem == subtitle_file.stem
and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS),
None
)
if not media_match:
continue # no lesson to navigate to - skip rather than dead-end
lesson_title = DynamicCourseParser._clean_lesson_name(media_match.stem)
lesson_relative = media_match.relative_to(course_dir).as_posix()
results.append({
'course_path': str(course_dir),
'course_name': course_dir.name,
'lesson_path': f"{lesson_relative}/{lesson_title.replace(' ', '_')}",
'lesson_title': lesson_title,
'snippet': _extract_subtitle_snippet(text, query_lower)
})
return results
def _humanize_days_ago(dt: datetime) -> str:
"""'Today' / 'Yesterday' / 'N days ago' / a plain date once it's old enough to matter less."""
days = (datetime.now().date() - dt.date()).days
if days <= 0:
return 'Today'
if days == 1:
return 'Yesterday'
if days < 14:
return f'{days} days ago'
return dt.strftime('%b %d')
def get_recently_added_courses(limit: int = 5) -> List[Dict[str, Any]]:
"""
Courses whose folder was most recently created/modified on disk, for
the dashboard's "Recently Added" card - separate from Recently Viewed
(which tracks what you've *watched*, not what showed up in the library).
"""
dated = []
for course_dir in get_all_course_dirs():
try:
mtime = course_dir.stat().st_mtime
except OSError:
continue
dated.append((mtime, course_dir))
dated.sort(key=lambda pair: pair[0], reverse=True)
results = []
for mtime, course_dir in dated[:limit]:
item = _course_summary(course_dir)
item['added_display'] = _humanize_days_ago(datetime.fromtimestamp(mtime))
results.append(item)
return results
def _scan_library_activity() -> Dict[str, Any]:
"""
One walk over every course's progress file, computing everything the
dashboard's stats card / stale-course nudges / activity heatmap need -
library_stats/stale_courses/activity_heatmap in index() all derive from
a single call to this rather than each re-scanning every course's
progress.json independently. Reads each course's small progress JSON
file directly, not by re-scanning course directory contents, so this
stays cheap regardless of how many files are inside each course.
"""
total_courses = 0
lessons_tracked = 0
completed_lessons = 0
watched_seconds = 0
total_duration_seconds = 0.0
active_dates = set()
activity_by_date: Dict[str, int] = {}
courses: List[Dict[str, Any]] = []
heatmap_cutoff = datetime.now().date() - timedelta(days=89)
for course_dir in get_all_course_dirs():
total_courses += 1
total_duration_seconds += _cached_course_duration_seconds(course_dir)
try:
with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
continue
course_total = 0
course_completed = 0
course_last_activity: Optional[datetime] = None
for key, entry in progress.items():
if key == 'last_accessed_path' or not isinstance(entry, dict):
continue
lessons_tracked += 1
course_total += 1
if entry.get('completed'):
completed_lessons += 1
course_completed += 1
watched_seconds += entry.get('duration_seconds') or 0
else:
watched_seconds += entry.get('progress_seconds') or 0
last_accessed = entry.get('last_accessed')
if not last_accessed:
continue
try:
accessed_dt = datetime.fromisoformat(last_accessed)
except ValueError:
continue
accessed_date = accessed_dt.date()
active_dates.add(accessed_date)
if course_last_activity is None or accessed_dt > course_last_activity:
course_last_activity = accessed_dt
if accessed_date >= heatmap_cutoff:
iso = accessed_date.isoformat()
activity_by_date[iso] = activity_by_date.get(iso, 0) + 1
if course_total > 0:
courses.append({
'path': str(course_dir),
'total': course_total,
'completed': course_completed,
'last_activity': course_last_activity
})
streak_days = 0
day = datetime.now().date()
while day in active_dates:
streak_days += 1
day -= timedelta(days=1)
return {
'total_courses': total_courses,
'lessons_tracked': lessons_tracked,
'completed_lessons': completed_lessons,
'watched_seconds': watched_seconds,
'total_duration_seconds': total_duration_seconds,
'streak_days': streak_days,
'activity_by_date': activity_by_date,
'courses': courses,
}
def get_random_incomplete_course() -> Optional[Path]:
"""A random course for the dashboard's "Surprise Me" pick - excludes
courses that are already fully watched where possible, falling back to
the whole library if everything's done."""
all_dirs = get_all_course_dirs()
if not all_dirs:
return None
scan = _scan_library_activity()
fully_completed = {c['path'] for c in scan['courses'] if c['total'] > 0 and c['completed'] >= c['total']}
candidates = [d for d in all_dirs if str(d) not in fully_completed]
return random.choice(candidates or all_dirs)
def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]:
"""Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result."""
remaining_seconds = max(0, scan['total_duration_seconds'] - scan['watched_seconds'])
return {
'total_courses': scan['total_courses'],
'lessons_tracked': scan['lessons_tracked'],
'completed_lessons': scan['completed_lessons'],
'watched_display': format_duration(scan['watched_seconds']),
# None until at least some course's duration cache has been
# populated (via viewing/searching the library or running
# "Precompute Lengths & Cover Art") - a remaining estimate from a
# library that's mostly uncached would just be misleadingly low.
'remaining_display': format_duration(remaining_seconds) if scan['total_duration_seconds'] else None,
'streak_days': scan['streak_days']
}
def format_stale_courses(scan: Dict[str, Any], threshold_days: int = 14, limit: int = 5) -> List[Dict[str, Any]]:
"""
Courses with some progress that haven't been touched in a while, oldest
first - courses with zero progress (never started) and fully completed
ones are both excluded, since neither is something to "pick back up."
"""
cutoff = datetime.now() - timedelta(days=threshold_days)
candidates = [
c for c in scan['courses']
if c['completed'] < c['total'] and c['last_activity'] and c['last_activity'] < cutoff
]
candidates.sort(key=lambda c: c['last_activity'])
results = []
for c in candidates[:limit]:
item = _course_summary(Path(c['path']))
days_ago = (datetime.now() - c['last_activity']).days
item['last_touched_display'] = f"{days_ago} day{'s' if days_ago != 1 else ''} ago"
results.append(item)
return results
def format_activity_heatmap(scan: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
The last 90 days as a flat list (oldest first) for the dashboard's
contribution-style calendar, each with an ISO date and that day's
activity count.
"""
activity_by_date = scan['activity_by_date']
today = datetime.now().date()
return [
{'date': (today - timedelta(days=offset)).isoformat(),
'count': activity_by_date.get((today - timedelta(days=offset)).isoformat(), 0)}
for offset in range(89, -1, -1)
]
# ---- Outline integration ----
# Deliberately kept separate from DEFAULT_SETTINGS/load_settings/save_settings:
# those all flow through GET /api/settings, which theme.js fetches on every
# page load - not where an API token should ever ride along.
OUTLINE_CONFIG_FILE = os.path.join(DATA_DIR, 'outline_config.json')
def load_outline_config() -> Dict[str, str]:
"""Load the Outline base URL + API token + default collection. Never exposed via GET beyond 'configured'."""
try:
if os.path.exists(OUTLINE_CONFIG_FILE):
with open(OUTLINE_CONFIG_FILE, 'r') as f:
data = json.load(f)
if isinstance(data, dict):
return {
'base_url': data.get('base_url', ''),
'api_token': data.get('api_token', ''),
'default_collection_id': data.get('default_collection_id', ''),
'default_collection_name': data.get('default_collection_name', '')
}
except (json.JSONDecodeError, OSError) as e:
print(f"Could not load Outline config: {e}")
return {'base_url': '', 'api_token': '', 'default_collection_id': '', 'default_collection_name': ''}
def save_outline_config(base_url: str, api_token: str,
default_collection_id: Optional[str] = None,
default_collection_name: Optional[str] = None) -> None:
"""
Save the Outline base URL / API token / default collection. A blank
api_token keeps the previously stored one, so the URL can be updated
without re-pasting it; default_collection_id/name are only touched when
explicitly passed (None means 'leave as-is'), so saving the URL/token
doesn't clear an already-chosen collection.
"""
current = load_outline_config()
current['base_url'] = base_url.rstrip('/')
if api_token:
current['api_token'] = api_token
if default_collection_id is not None:
current['default_collection_id'] = default_collection_id
if default_collection_name is not None:
current['default_collection_name'] = default_collection_name
os.makedirs(DATA_DIR, exist_ok=True)
with open(OUTLINE_CONFIG_FILE, 'w') as f:
json.dump(current, f, indent=2)
def _outline_request(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
POST to the Outline API (every Outline endpoint is POST, even 'list'
ones) with the stored token. Raises RuntimeError with a readable message
on any failure - not configured, unreachable, or a non-2xx response.
"""
config = load_outline_config()
if not config['base_url'] or not config['api_token']:
raise RuntimeError('Outline is not configured')
url = f"{config['base_url']}/api/{path}"
body = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(url, data=body, method='POST', headers={
'Authorization': f"Bearer {config['api_token']}",
'Content-Type': 'application/json',
'Accept': 'application/json',
})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
detail = e.read().decode('utf-8', errors='replace')
raise RuntimeError(f'Outline returned {e.code}: {detail[:200]}')
except urllib.error.URLError as e:
raise RuntimeError(f'Could not reach Outline: {e.reason}')
def list_outline_topics() -> List[Dict[str, str]]:
"""
Top-level documents in the configured default collection - each one is
a "topic" a lesson note can be filed under. Fetches every document in
the collection and filters to parentDocumentId is None locally, rather
than relying on documents.list's collectionId/parentDocumentId filter
params directly - both are marked deprecated in Outline's own API spec
and their exact recursive-vs-top-level behavior isn't documented, so
filtering the response ourselves is the version that can't be wrong.
"""
config = load_outline_config()
collection_id = config['default_collection_id']
if not collection_id:
raise RuntimeError('No default Outline collection set - pick one in Settings first')
result = _outline_request('documents.list', {'collectionId': collection_id, 'limit': 100})
return [
{'id': d['id'], 'name': d['title']}
for d in result.get('data', [])
if not d.get('parentDocumentId')
]
def resolve_outline_topic(name: str) -> Dict[str, str]:
"""
Find an existing topic document (a top-level document in the configured
default collection) with this exact title, or create one. Matching by
name first (rather than always creating) means naming a "new" topic
that happens to match one you already made doesn't spawn a duplicate.
"""
for topic in list_outline_topics():
if topic['name'] == name:
return topic
config = load_outline_config()
created = _outline_request('documents.create', {
'title': name,
'text': '',
'collectionId': config['default_collection_id'],
'publish': True
})
return {'id': created['data']['id'], 'name': name}
def push_note_to_outline(course: Course, lesson_path: str, lesson_title: str,
topic_id: str, new_topic_name: str) -> Dict[str, Any]:
"""
Push a lesson's saved note to Outline as a child document nested under
the chosen topic document (which itself lives in the configured default
collection - see list_outline_topics). Creates the note document on the
first push and updates the same one (by the id stashed in the progress
file) on every push after that.
new_topic_name is a fallback for a topic that somehow never got resolved
client-side (see resolve_outline_topic) - the normal path resolves a
brand-new topic name to a real document id as soon as it's typed
(POST /api/outline/resolve-topic), specifically so this fire-and-forget,
can't-read-the-response push never has to decide "is this actually a
new topic" on its own. A page that fires pagehide more than once for
the same view (bfcache restore, for instance) would otherwise re-send
the same "new topic" intent every time and create a duplicate topic
document per navigation.
"""
config = load_outline_config()
if not config['default_collection_id']:
return {'success': False, 'error': 'No default Outline collection set - pick one in Settings first'}
progress = ProgressTracker.load_progress(course)
entry = progress.get(lesson_path, {})
notes = ProgressTracker._notes_from_entry(entry)
if not notes:
return {'success': False, 'error': 'No note to push'}
note = "\n\n".join(
f"**[{format_timestamp(n.get('timestamp_seconds'))}]** {n.get('text', '')}"
if n.get('timestamp_seconds') is not None else n.get('text', '')
for n in notes
)
if not topic_id:
if not new_topic_name:
return {'success': False, 'error': 'No topic selected'}
topic_id = resolve_outline_topic(new_topic_name)['id']
ProgressTracker.set_lesson_outline_topic(course, lesson_path, topic_id, '')
title = f"{lesson_title}{course.name}"
document_id = entry.get('outline_document_id')
if document_id:
_outline_request('documents.update', {'id': document_id, 'title': title, 'text': note})
else:
created = _outline_request('documents.create', {
'title': title,
'text': note,
'collectionId': config['default_collection_id'],
'parentDocumentId': topic_id,
'publish': True
})
document_id = created['data']['id']
ProgressTracker.set_lesson_outline_document_id(course, lesson_path, document_id)
return {'success': True, 'document_id': document_id, 'topic_id': topic_id}
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 remove_recent_views_for_path(path: str) -> List[Dict[str, Any]]:
"""Drop every Recently Viewed entry for one course path - there can be
several, one per lesson. Used to clear a stale reference left behind
when a course was deleted/moved outside the app."""
entries = get_recent_views()
normalized = os.path.abspath(path)
remaining = [e for e in entries if e.get('course_path') != normalized]
if remaining != entries:
os.makedirs(DATA_DIR, exist_ok=True)
with open(RECENT_VIEWS_FILE, 'w') as f:
json.dump(remaining, f, indent=2)
return remaining
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
def _resolve_lesson_progress_key(course: Course, lesson: Lesson, progress: Dict[str, Any]) -> Optional[str]:
"""
Match a Lesson to its progress-file key. Lessons have historically been
keyed two ways - by their relative file path alone, or with the
lesson's title suffix appended (see get_lesson_url) - so check both
rather than assuming one.
"""
lesson_path = os.path.relpath(lesson.path, course.path).replace('\\', '/')
if lesson_path.startswith('/'):
lesson_path = lesson_path[1:]
if lesson_path in progress:
return lesson_path
lesson_path_with_title = f"{lesson_path}/{lesson.title.replace(' ', '_')}"
if lesson_path_with_title in progress:
return lesson_path_with_title
return None
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 notes - this call has no opinion on them, so don't let a
# routine playback-progress save wipe them out.
if existing.get('notes'):
entry['notes'] = existing['notes']
elif 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 touch_lesson_accessed(course: Course, lesson_path: str):
"""
Record that a lesson was opened, without touching its saved
completed/progress_seconds/duration_seconds - update_lesson_progress
is for the client reporting real playback progress, and calling it
(with its completed=False, progress_seconds=0 defaults) just for
viewing a page would silently reset an already-watched lesson back
to 0% every time it's opened, before the client gets a chance to
report anything real.
"""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
entry['last_accessed'] = datetime.now().isoformat()
progress['last_accessed_path'] = lesson_path
ProgressTracker.save_progress(course, progress)
@staticmethod
def _notes_from_entry(entry: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
This entry's timestamped notes, transparently upgrading a legacy
single-string `note` field into a one-item list so every caller can
treat notes as a list without caring which shape is on disk.
"""
if entry.get('notes'):
return entry['notes']
legacy = entry.get('note')
if legacy:
return [{
'id': 'legacy',
'timestamp_seconds': None,
'text': legacy,
'created_at': entry.get('last_accessed', '')
}]
return []
@staticmethod
def get_lesson_notes(course: Course, lesson_path: str) -> List[Dict[str, Any]]:
"""A lesson's timestamped notes, newest-shape-or-migrated-legacy."""
progress = ProgressTracker.load_progress(course)
return ProgressTracker._notes_from_entry(progress.get(lesson_path, {}))
@staticmethod
def add_lesson_note(course: Course, lesson_path: str, text: str,
timestamp_seconds: Optional[int]) -> List[Dict[str, Any]]:
"""Append a new timestamped note, migrating a legacy single-note entry to the list shape if needed."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
notes = ProgressTracker._notes_from_entry(entry)
notes.append({
'id': uuid.uuid4().hex[:8],
'text': text,
'timestamp_seconds': timestamp_seconds,
'created_at': datetime.now().isoformat()
})
entry['notes'] = notes
entry.pop('note', None)
ProgressTracker.save_progress(course, progress)
return notes
@staticmethod
def update_lesson_note_text(course: Course, lesson_path: str, note_id: str, text: str) -> List[Dict[str, Any]]:
"""Edit one timestamped note's text in place."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
notes = ProgressTracker._notes_from_entry(entry)
for note in notes:
if note.get('id') == note_id:
note['text'] = text
break
entry['notes'] = notes
entry.pop('note', None)
ProgressTracker.save_progress(course, progress)
return notes
@staticmethod
def delete_lesson_note(course: Course, lesson_path: str, note_id: str) -> List[Dict[str, Any]]:
"""Remove one timestamped note."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
notes = [n for n in ProgressTracker._notes_from_entry(entry) if n.get('id') != note_id]
entry['notes'] = notes
entry.pop('note', None)
ProgressTracker.save_progress(course, progress)
return notes
@staticmethod
def set_lesson_outline_topic(course: Course, lesson_path: str, topic_id: str, topic_name: str):
"""
Remember which Outline topic (collection) a lesson's note should push
to - an existing collection id, or a not-yet-created topic name (see
push_note_to_outline). Clearing the pick (both blank) removes it so
pagehide stops firing a push for this lesson.
"""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
if topic_id:
entry['outline_topic_id'] = topic_id
entry.pop('outline_topic_name', None)
elif topic_name:
entry['outline_topic_name'] = topic_name
entry.pop('outline_topic_id', None)
else:
entry.pop('outline_topic_id', None)
entry.pop('outline_topic_name', None)
ProgressTracker.save_progress(course, progress)
@staticmethod
def set_lesson_outline_document_id(course: Course, lesson_path: str, document_id: str):
"""Remember the Outline document a lesson's note was pushed to, so the next push updates it instead of creating a duplicate."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
entry['outline_document_id'] = document_id
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)
# A lesson's duration is normally only known once it's been played
# (the player reports it back from the <video>/<audio> element) -
# for anything not yet watched, fall back to the ffprobe-derived
# duration cache so per-lesson lengths and the course's "~X
# remaining" estimate are meaningful from the very first visit,
# not just after you've started watching.
course_dir = Path(course.path)
media_files = [
f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
]
duration_cache = _ensure_media_durations_cached(course_dir, media_files) if media_files else {}
def apply_to_node(node: DirectoryNode):
# Apply progress to lessons in this node
for lesson in node.lessons:
key = _resolve_lesson_progress_key(course, lesson, progress)
if key:
entry = progress[key]
lesson.completed = entry.get('completed', False)
lesson.last_accessed = entry.get('last_accessed')
lesson.progress_seconds = entry.get('progress_seconds', 0)
lesson.duration_seconds = entry.get('duration_seconds', 0)
if not lesson.duration_seconds:
media_key = lesson.video_file or lesson.audio_file
cached_duration = duration_cache.get(media_key, {}).get('duration_seconds') if media_key else None
if cached_duration:
lesson.duration_seconds = int(cached_duration)
# 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)
def course_has_any_notes(course: Course) -> bool:
"""Whether any lesson in the course has a saved note - used to decide whether to offer a study-guide download."""
progress = ProgressTracker.load_progress(course)
return any(
isinstance(entry, dict) and ProgressTracker._notes_from_entry(entry)
for key, entry in progress.items() if key != 'last_accessed_path'
)
def format_timestamp(seconds: Optional[float]) -> Optional[str]:
"""Render a note's captured playback position as "MM:SS", or None if it wasn't timestamped."""
if seconds is None:
return None
seconds = int(seconds)
return f"{seconds // 60}:{seconds % 60:02d}"
def get_all_notes() -> List[Dict[str, Any]]:
"""
Every timestamped note across the whole library, newest first - a note
is otherwise only visible from its own lesson page (or Outline, once
pushed), so this is the one place to see everything you've written. A
lesson with several notes contributes one row per note.
"""
notes = []
for course_dir in get_all_course_dirs():
try:
with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
continue
for lesson_path, entry in progress.items():
if lesson_path == 'last_accessed_path' or not isinstance(entry, dict):
continue
for note in ProgressTracker._notes_from_entry(entry):
created_at = note.get('created_at') or entry.get('last_accessed', '')
notes.append({
'course_path': str(course_dir),
'course_name': course_dir.name,
'lesson_path': lesson_path,
'lesson_title': lesson_path.rsplit('/', 1)[-1].replace('_', ' '),
'note_id': note.get('id'),
'text': note.get('text', ''),
'timestamp_seconds': note.get('timestamp_seconds'),
'timestamp_label': format_timestamp(note.get('timestamp_seconds')),
'created_at': created_at,
'pushed_to_outline': bool(entry.get('outline_document_id'))
})
notes.sort(key=lambda n: n['created_at'], reverse=True)
return notes
def build_study_guide_markdown(course: Course) -> str:
"""
Compile every note written for a course into one markdown document,
following the course's own section/lesson structure - only sections
and lessons that actually have a note appear; everything else is
skipped rather than padding the guide with empty headings.
"""
progress = ProgressTracker.load_progress(course)
def node_has_notes(node: DirectoryNode) -> bool:
for lesson in node.lessons:
key = _resolve_lesson_progress_key(course, lesson, progress)
if key and ProgressTracker._notes_from_entry(progress[key]):
return True
return any(node_has_notes(child) for child in node.children.values())
lines = [f"# {course.name}", ""]
def walk(node: DirectoryNode, heading_level: int):
if node.name and node.name != "Course Root":
if not node_has_notes(node):
return
lines.append(f"{'#' * min(heading_level, 6)} {node.name}")
lines.append("")
for lesson in node.lessons:
key = _resolve_lesson_progress_key(course, lesson, progress)
lesson_notes = ProgressTracker._notes_from_entry(progress[key]) if key else []
if not lesson_notes:
continue
lines.append(f"{'#' * min(heading_level + 1, 6)} {lesson.title}")
lines.append("")
for note in lesson_notes:
ts = format_timestamp(note.get('timestamp_seconds'))
prefix = f"**[{ts}]** " if ts else ""
lines.append(f"- {prefix}{note.get('text', '')}")
lines.append("")
for child in node.children.values():
walk(child, heading_level + 1)
walk(course.root_node, 2)
return "\n".join(lines)
# Global course storage
current_course = None
def format_duration(seconds: int) -> str:
"""Render a duration in seconds as e.g. '3h 24m' or '45m' (under an hour)."""
total_minutes = max(0, int(seconds)) // 60
hours, minutes = divmod(total_minutes, 60)
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
app.jinja_env.filters['format_duration'] = format_duration
@app.route('/')
def index():
"""Main dashboard"""
global current_course
recent_views = get_recent_views_for_display()[:8]
if current_course is None:
# One scan covers stats/stale-courses/heatmap - see _scan_library_activity.
scan = _scan_library_activity()
return render_template('course_dashboard.html',
course=None,
stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0},
recent_views=recent_views,
recently_added=get_recently_added_courses(),
library_stats=format_library_stats(scan),
stale_courses=format_stale_courses(scan),
activity_heatmap=format_activity_heatmap(scan),
next_up=get_next_up_courses(),
favorites=get_favorite_courses(),
active_tab='home')
# Apply progress data to tree
ProgressTracker.apply_progress_to_tree(current_course)
stats = ProgressTracker.get_completion_stats(current_course)
if stats.get('total_duration_seconds'):
stats['remaining_display'] = format_duration(stats['remaining_seconds'])
resume_lesson = None
if current_course.last_accessed_path:
lesson, _ = find_lesson_in_tree(current_course.root_node, current_course.last_accessed_path)
if lesson:
resume_lesson = {'title': lesson.title, 'url': get_lesson_url(lesson, current_course.path)}
return render_template('course_dashboard.html',
course=current_course,
stats=stats,
recent_views=recent_views,
has_note=bool(course_has_any_notes(current_course)),
is_queued=os.path.abspath(current_course.path) in get_next_up_paths(),
is_favorited=os.path.abspath(current_course.path) in get_favorite_paths(),
resume_lesson=resume_lesson,
active_tab='home')
@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']
})
def search_manage_tree(query: str, library_root: str) -> List[Dict[str, Any]]:
"""
Every course or category folder anywhere in the library whose name
contains `query` (case-insensitive) - lets Manage Library jump
straight to something instead of manually expanding the tree level by
level. Includes hidden items (flagged 'hidden': True), unlike the
normal Library browser - finding something specifically to un-hide it
is exactly what this is for. Stops descending once a course is found,
same as everywhere else, so a course's internal chapter folders never
show up as if they were independently manageable.
"""
query_lower = query.lower().strip()
if not query_lower:
return []
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:
is_course = _looks_like_course(entry)
is_hidden = os.path.abspath(str(entry)) in hidden_set
if query_lower in entry.name.lower():
results.append({
'type': 'course' if is_course else 'directory',
'name': entry.name,
'path': str(entry),
'hidden': is_hidden,
})
if not is_course:
walk(entry)
walk(Path(library_root))
return results
@app.route('/api/library/manage-search')
def manage_search_api():
"""Search endpoint backing Manage Library's search box (see
search_manage_tree)."""
query = request.args.get('q', '')
library_root = os.path.abspath(get_library_root())
return jsonify({'results': search_manage_tree(query, library_root)})
@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(query)
return jsonify({'library_path': library_root, 'results': results})
@app.route('/api/library/refresh', methods=['POST'])
def refresh_library_api():
"""Clear cached library-scan results - for when files were added/removed directly on disk (e.g. on the NAS) rather than through the app, which the cache's TTL would otherwise take up to 5 minutes to notice on its own."""
invalidate_cache()
return jsonify({'success': True})
@app.route('/api/unsorted/scan')
def scan_unsorted_api():
"""Propose a destination for every item currently sitting in the Unsorted folder. Read-only - nothing moves until /api/unsorted/apply is called."""
library_root = get_library_root()
unsorted_root = os.path.join(library_root, UNSORTED_FOLDER_NAME)
if not os.path.isdir(unsorted_root):
return jsonify({'items': [], 'unsorted_exists': False, 'unsorted_path': unsorted_root})
items = _list_unsorted_items()
categories = _build_category_index()
bonus_df = _bonus_token_frequency(categories)
results = []
for item in items:
proposal = _propose_destination(item.name, categories, bonus_df)
results.append({'name': item.name, 'path': str(item), **proposal})
category_paths = sorted((c['relative'] for c in categories), key=str.lower)
return jsonify({
'items': results,
'unsorted_exists': True,
'unsorted_path': unsorted_root,
'category_count': len(categories),
'categories': category_paths,
})
@app.route('/api/unsorted/apply', methods=['POST'])
def apply_unsorted_api():
"""
Move a batch of Unsorted items to their (possibly user-edited)
destinations, creating new subfolders as needed, and optionally renaming
each item's folder in the same step (move + rename is one filesystem
operation either way, so there's no reason to make it two). Every source
must actually be inside the Unsorted folder and every destination must
stay inside the library root and outside Unsorted itself - guards
against a hand-edited destination path escaping the library, the same
zip-slip-style check used for backup restore.
"""
data = request.json or {}
moves = data.get('moves')
if not isinstance(moves, list) or not moves:
return jsonify({'success': False, 'error': 'No moves provided'}), 400
library_root = os.path.abspath(get_library_root())
unsorted_root = os.path.abspath(os.path.join(library_root, UNSORTED_FOLDER_NAME))
moved = []
errors = []
for move in moves:
source = (move or {}).get('source', '')
destination_relative = ((move or {}).get('destination') or '').strip().strip('/')
if not source or not destination_relative:
errors.append({'source': source, 'error': 'Missing source or destination'})
continue
source_abs = os.path.abspath(source)
if not (source_abs == unsorted_root or source_abs.startswith(unsorted_root + os.sep)):
errors.append({'source': source, 'error': 'Source is not inside the Unsorted folder'})
continue
if not os.path.isdir(source_abs):
errors.append({'source': source, 'error': 'No longer exists - already moved?'})
continue
dest_dir = os.path.abspath(os.path.join(library_root, destination_relative))
if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
errors.append({'source': source, 'error': 'Destination is outside the library'})
continue
if dest_dir == unsorted_root or dest_dir.startswith(unsorted_root + os.sep):
errors.append({'source': source, 'error': 'Destination cannot be inside Unsorted'})
continue
final_name = ((move or {}).get('name') or '').strip()
if not final_name:
final_name = os.path.basename(source_abs)
if os.sep in final_name or (os.altsep and os.altsep in final_name) or final_name in ('.', '..'):
errors.append({'source': source, 'error': f'"{final_name}" is not a valid folder name'})
continue
final_path = os.path.join(dest_dir, final_name)
if os.path.exists(final_path):
errors.append({'source': source, 'error': f'"{final_name}" already exists at that destination'})
continue
try:
os.makedirs(dest_dir, exist_ok=True)
shutil.move(source_abs, final_path)
moved.append({'source': source, 'destination': final_path})
except OSError as e:
errors.append({'source': source, 'error': str(e)})
invalidate_cache()
undo_entries = [{'old_path': m['source'], 'new_path': m['destination']} for m in moved]
_record_undo_batch(f'Sort Unsorted ({len(moved)} item{"" if len(moved) == 1 else "s"})', undo_entries)
return jsonify({'success': True, 'moved': len(moved), 'errors': errors})
def _directory_size_bytes(directory: Path, max_depth: int = 20) -> int:
"""Total size of every file anywhere under `directory` (bounded depth,
same caution used elsewhere in File Management for deep/unusual
trees). Skips anything it can't read rather than failing the whole
scan - a NAS with mixed permissions shouldn't block a size report for
everything else."""
total = 0
def walk(d: Path, depth: int) -> None:
nonlocal total
try:
entries = list(d.iterdir())
except (PermissionError, OSError):
return
for e in entries:
if e.name.startswith('.'):
continue
try:
if e.is_file():
total += e.stat().st_size
elif e.is_dir() and depth < max_depth:
walk(e, depth + 1)
except OSError:
continue
walk(directory, 0)
return total
def _format_bytes(n: int) -> str:
"""Human-readable size, e.g. 12.3 GB - matches the precision people
actually care about for a course library (no need for KB granularity)."""
size = float(n)
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
if size < 1024 or unit == 'TB':
return f'{size:.0f} {unit}' if unit == 'B' else f'{size:.1f} {unit}'
size /= 1024
return f'{size:.1f} TB'
def _list_entries_with_sizes(directory: Path, skip_names: Optional[set] = None) -> List[Dict[str, Any]]:
"""
Every immediate child of `directory` with its total size - a
subdirectory gets its full recursive size (via _directory_size_bytes),
a loose file gets its own size directly. Sorted largest-first. Shared
by the top-level Storage Usage scan and its drill-down into a single
folder, which is otherwise the same operation one level deeper.
"""
skip_names = skip_names or set()
try:
children = [
p for p in directory.iterdir()
if not p.name.startswith('.') and p.name not in skip_names
]
except (PermissionError, OSError):
return []
entries = []
for entry in children:
try:
is_dir = entry.is_dir()
size = _directory_size_bytes(entry) if is_dir else entry.stat().st_size
except OSError:
continue
entries.append({
'name': entry.name,
'path': str(entry),
'bytes': size,
'human': _format_bytes(size),
'is_dir': is_dir,
})
entries.sort(key=lambda e: e['bytes'], reverse=True)
return entries
@app.route('/api/library/storage-usage')
def storage_usage_api():
"""
Disk usage of a library folder's immediate contents, sorted
largest-first, for spotting what's eating the most space on the NAS.
With no `path` param, scans the library root's top-level folders
(category or a bare course sitting directly at the root); with `path`,
drills into that folder instead (must resolve inside the library
root). Manually triggered like Duplicate Courses - a full recursive
size scan touches every file under each entry, too expensive to run
automatically on page load.
"""
library_root = Path(get_library_root()).resolve()
subpath = (request.args.get('path') or '').strip()
if subpath:
base = Path(subpath).resolve()
try:
base.relative_to(library_root)
except ValueError:
return jsonify({'error': 'Path is outside the library'}), 400
if not base.is_dir():
return jsonify({'error': 'Not a directory'}), 400
else:
base = library_root
is_root = base == library_root
skip = {UNSORTED_FOLDER_NAME} if is_root else set()
entries = _list_entries_with_sizes(base, skip)
total_bytes = sum(e['bytes'] for e in entries)
return jsonify({
'entries': entries,
'total_bytes': total_bytes,
'total_human': _format_bytes(total_bytes),
'path': str(base),
'is_root': is_root,
'parent_path': str(base.parent) if not is_root else None,
})
@app.route('/api/library/categories')
def library_categories_api():
"""Every category/subcategory folder in the library, for destination
pickers - Sort Unsorted's own picker gets this from /api/unsorted/scan
already; this is the same list for callers (like Manage Library's move
action) that need it without depending on an Unsorted-folder scan."""
categories = _build_category_index()
return jsonify({'categories': sorted((c['relative'] for c in categories), key=str.lower)})
@app.route('/api/library/move', methods=['POST'])
def move_library_item_api():
"""
Move (and optionally rename) any course or folder already in the
library to a different location - the general-purpose version of
/api/unsorted/apply, for reorganizing something already filed rather
than just-arrived. Same source/destination-inside-library-root safety
checks, plus one apply_unsorted_api doesn't need: the destination
can't be inside the source's own subtree, since here the source can
itself be a category folder with children, not just a leaf course.
"""
data = request.json or {}
source = (data.get('source') or '').strip()
destination_relative = (data.get('destination') or '').strip().strip('/')
final_name = (data.get('name') or '').strip()
if not source or not destination_relative:
return jsonify({'success': False, 'error': 'Missing source or destination'}), 400
library_root = os.path.abspath(get_library_root())
source_abs = os.path.abspath(source)
if not (source_abs == library_root or source_abs.startswith(library_root + os.sep)):
return jsonify({'success': False, 'error': 'Source is outside the library'}), 403
if source_abs == library_root:
return jsonify({'success': False, 'error': 'Cannot move the library root itself'}), 400
if not os.path.isdir(source_abs):
return jsonify({'success': False, 'error': 'Source no longer exists'}), 404
dest_dir = os.path.abspath(os.path.join(library_root, destination_relative))
if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
return jsonify({'success': False, 'error': 'Destination is outside the library'}), 403
if dest_dir == source_abs or dest_dir.startswith(source_abs + os.sep):
return jsonify({'success': False, 'error': 'Cannot move a folder into itself'}), 400
if not final_name:
final_name = os.path.basename(source_abs)
if os.sep in final_name or (os.altsep and os.altsep in final_name) or final_name in ('.', '..'):
return jsonify({'success': False, 'error': f'"{final_name}" is not a valid folder name'}), 400
final_path = os.path.join(dest_dir, final_name)
if final_path == source_abs:
return jsonify({'success': False, 'error': 'Already there'}), 400
if os.path.exists(final_path):
return jsonify({'success': False, 'error': f'"{final_name}" already exists at that destination'}), 409
try:
os.makedirs(dest_dir, exist_ok=True)
shutil.move(source_abs, final_path)
except OSError as e:
return jsonify({'success': False, 'error': str(e)}), 500
invalidate_cache()
rebase_library_path(source_abs, final_path)
_record_undo_batch(f'Move "{os.path.basename(source_abs)}"',
[{'old_path': source_abs, 'new_path': final_path}])
global current_course
active_course_reset = False
if current_course is not None:
course_abs = os.path.abspath(current_course.path)
if course_abs == source_abs or course_abs.startswith(source_abs + os.sep):
current_course = None
active_course_reset = True
return jsonify({'success': True, 'new_path': final_path, 'active_course_reset': active_course_reset})
@app.route('/api/library/bulk-move', methods=['POST'])
def bulk_move_library_items_api():
"""
Move several courses/folders already in the library to the same
destination in one batch - the multi-select complement to
/api/library/move for reorganizing many items at once. Same safety
checks as a single move, applied per item; continues past individual
failures and reports each outcome, the same pattern Bulk Rename uses.
"""
data = request.json or {}
sources = data.get('sources') or []
destination_relative = (data.get('destination') or '').strip().strip('/')
if not isinstance(sources, list) or not sources or not destination_relative:
return jsonify({'success': False, 'error': 'sources and destination are required'}), 400
library_root = os.path.abspath(get_library_root())
dest_dir = os.path.abspath(os.path.join(library_root, destination_relative))
if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
return jsonify({'success': False, 'error': 'Destination is outside the library'}), 403
global current_course
results = []
undo_entries = []
active_course_reset = False
for source in sources:
source_abs = os.path.abspath(source)
result = {'source': source}
if not (source_abs == library_root or source_abs.startswith(library_root + os.sep)) or source_abs == library_root:
result.update(success=False, error='Source is outside the library')
elif dest_dir == source_abs or dest_dir.startswith(source_abs + os.sep):
result.update(success=False, error='Cannot move a folder into itself')
elif not os.path.isdir(source_abs):
result.update(success=False, error='No longer exists')
else:
final_name = os.path.basename(source_abs)
final_path = os.path.join(dest_dir, final_name)
if final_path == source_abs:
result.update(success=False, error='Already there')
elif os.path.exists(final_path):
result.update(success=False, error=f'"{final_name}" already exists at that destination')
else:
try:
os.makedirs(dest_dir, exist_ok=True)
shutil.move(source_abs, final_path)
rebase_library_path(source_abs, final_path)
undo_entries.append({'old_path': source_abs, 'new_path': final_path})
result.update(success=True, new_path=final_path)
if current_course is not None:
course_abs = os.path.abspath(current_course.path)
if course_abs == source_abs or course_abs.startswith(source_abs + os.sep):
current_course = None
active_course_reset = True
except OSError as e:
result.update(success=False, error=str(e))
results.append(result)
invalidate_cache()
moved_count = len(undo_entries)
_record_undo_batch(f'Bulk move ({moved_count} item{"" if moved_count == 1 else "s"}) to "{destination_relative}"',
undo_entries)
return jsonify({'success': True, 'moved': moved_count, 'results': results, 'active_course_reset': active_course_reset})
@app.route('/api/library/undo-last')
def undo_last_status_api():
"""Whether there's a move/rename batch available to undo, and what it
was - lets the UI show a specific "Undo: <label>" instead of a blind
button, or hide it entirely when there's nothing to undo."""
if not _undo_history:
return jsonify({'available': False})
batch = _undo_history[-1]
return jsonify({'available': True, 'label': batch['label'], 'count': len(batch['entries'])})
@app.route('/api/library/undo-last', methods=['POST'])
def undo_last_action_api():
"""
Reverse the most recent move/rename batch (Sort Unsorted apply, Bulk
Rename apply, a Manage Library move/rename, a bulk move) as a single
action, moving/renaming each entry back to where it came from.
Re-checks every entry before touching it - the destination must still
exist and the original location must still be free - since the
filesystem may have changed since the batch was recorded; a partial
failure still pops the batch (retrying the same undo again wouldn't
help) and reports exactly what did and didn't reverse.
"""
if not _undo_history:
return jsonify({'success': False, 'error': 'Nothing to undo'}), 400
batch = _undo_history.pop()
library_root = os.path.abspath(get_library_root())
results = []
global current_course
active_course_reset = False
for entry in reversed(batch['entries']):
old_abs = os.path.abspath(entry['old_path'])
new_abs = os.path.abspath(entry['new_path'])
result = {'old_path': entry['old_path'], 'new_path': entry['new_path']}
if not (new_abs == library_root or new_abs.startswith(library_root + os.sep)):
result.update(success=False, error='Outside the library')
elif not os.path.isdir(new_abs):
result.update(success=False, error='No longer exists - already moved or renamed again?')
elif os.path.exists(old_abs):
result.update(success=False, error='Original location is occupied again')
else:
try:
os.makedirs(os.path.dirname(old_abs), exist_ok=True)
shutil.move(new_abs, old_abs)
rebase_library_path(new_abs, old_abs)
result['success'] = True
if current_course is not None:
course_abs = os.path.abspath(current_course.path)
if course_abs == new_abs or course_abs.startswith(new_abs + os.sep):
current_course = None
active_course_reset = True
except OSError as e:
result.update(success=False, error=str(e))
results.append(result)
invalidate_cache()
reversed_count = sum(1 for r in results if r['success'])
return jsonify({
'success': True,
'label': batch['label'],
'reversed': reversed_count,
'total': len(results),
'results': results,
'active_course_reset': active_course_reset,
})
@app.route('/api/library/duplicate-lessons')
def duplicate_lesson_files_api():
"""Scan for lesson files within the same course/folder that look like
the same lesson downloaded twice (see _find_duplicate_lesson_files).
Manual trigger, same reasoning as Duplicate Courses."""
groups = _find_duplicate_lesson_files()
return jsonify({'groups': groups})
@app.route('/api/library/delete-file', methods=['POST'])
def delete_library_file_api():
"""
Permanently delete a single media file from disk - the file-level
counterpart to /api/library/delete, used by Duplicate Lesson Files.
Scoped to files (not directories) inside the library root with a
recognized media extension, so it can't be pointed at something else.
"""
data = request.json or {}
path = (data.get('path') or '').strip()
if not path:
return jsonify({'success': False, 'error': 'Missing path'}), 400
library_root = os.path.abspath(get_library_root())
target_abs = os.path.abspath(path)
if not target_abs.startswith(library_root + os.sep):
return jsonify({'success': False, 'error': 'Path is outside the library'}), 403
if not os.path.isfile(target_abs):
return jsonify({'success': False, 'error': 'No longer exists - already deleted?'}), 404
if Path(target_abs).suffix.lower() not in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
return jsonify({'success': False, 'error': 'Not a media file'}), 400
try:
os.remove(target_abs)
except OSError as e:
return jsonify({'success': False, 'error': str(e)}), 500
invalidate_cache()
return jsonify({'success': True})
@app.route('/api/library/duplicates')
def duplicate_courses_api():
"""Scan for courses that look like copies of each other by name (see
_find_duplicate_courses). Not run automatically like Sort Unsorted's
scan - it's O(n^2) over every course in the library, so it's a manual
trigger instead."""
groups = _find_duplicate_courses()
return jsonify({'groups': groups, 'course_count': len(get_all_course_dirs())})
@app.route('/api/library/duplicates/ignore', methods=['POST'])
def ignore_duplicate_group_api():
"""Mark every pairwise combination within a reviewed duplicate group as
confirmed-not-duplicates, so the same match doesn't resurface on the
next scan."""
data = request.json or {}
paths = data.get('paths') or []
if not isinstance(paths, list) or len(paths) < 2:
return jsonify({'success': False, 'error': 'Need at least 2 paths'}), 400
pairs = list(itertools.combinations(sorted(set(paths)), 2))
add_ignored_duplicate_pairs(pairs)
return jsonify({'success': True, 'ignored_pairs': len(pairs)})
@app.route('/api/library/duplicates/ignored')
def list_ignored_duplicates_api():
"""Every pair currently excluded from duplicate scans, for a 'currently
ignored' review list - the same reversible-curation pattern hidden
paths already use."""
result = []
for path_a, path_b in sorted(get_ignored_duplicate_pairs()):
result.append({
'a': {'path': path_a, 'name': os.path.basename(path_a.rstrip(os.sep)) or path_a},
'b': {'path': path_b, 'name': os.path.basename(path_b.rstrip(os.sep)) or path_b},
})
return jsonify({'ignored': result})
@app.route('/api/library/duplicates/ignore', methods=['DELETE'])
def restore_ignored_duplicate_api():
"""Un-ignore one pair, so it's eligible to show up in duplicate scans again."""
data = request.json or {}
path_a = data.get('a', '')
path_b = data.get('b', '')
if not path_a or not path_b:
return jsonify({'success': False, 'error': 'Missing pair'}), 400
remove_ignored_duplicate_pair(path_a, path_b)
return jsonify({'success': True})
@app.route('/api/library/delete', methods=['POST'])
def delete_library_item_api():
"""
Permanently delete a course/folder from disk - the one truly
destructive action in this app, so it's scoped tightly: the path must
be inside the library root and can't be the root itself. The client is
expected to have already confirmed with the user (Duplicate Courses is
the only caller today). Also clears any hidden/Next-Up/Recently-Viewed
references to the deleted path, the same cleanup Clean Up Stale
References would otherwise have to do after the fact.
"""
data = request.json or {}
path = (data.get('path') or '').strip()
if not path:
return jsonify({'success': False, 'error': 'Missing path'}), 400
library_root = os.path.abspath(get_library_root())
target_abs = os.path.abspath(path)
if not (target_abs == library_root or target_abs.startswith(library_root + os.sep)):
return jsonify({'success': False, 'error': 'Path is outside the library'}), 403
if target_abs == library_root:
return jsonify({'success': False, 'error': 'Cannot delete the library root itself'}), 400
if not os.path.isdir(target_abs):
return jsonify({'success': False, 'error': 'No longer exists - already deleted?'}), 404
try:
shutil.rmtree(target_abs)
except OSError as e:
return jsonify({'success': False, 'error': str(e)}), 500
invalidate_cache()
set_path_hidden(target_abs, False)
set_path_queued(target_abs, False)
remove_recent_views_for_path(target_abs)
global current_course
active_course_reset = False
if current_course is not None:
course_abs = os.path.abspath(current_course.path)
if course_abs == target_abs or course_abs.startswith(target_abs + os.sep):
current_course = None
active_course_reset = True
return jsonify({'success': True, 'active_course_reset': active_course_reset})
@app.route('/api/library/stale-references')
def stale_references_api():
"""Entries in hidden_paths/next_up/recent_views pointing at paths no
longer on disk (see _find_stale_references). Read-only - nothing is
removed until /api/library/stale-references/clean is called."""
return jsonify(_find_stale_references())
@app.route('/api/library/stale-references/clean', methods=['POST'])
def clean_stale_references_api():
"""Remove specific stale entries the client reviewed via the scan
above. Re-checks each path still doesn't exist before removing it, in
case it became valid again between scan and apply."""
data = request.json or {}
items = data.get('items') or []
if not isinstance(items, list) or not items:
return jsonify({'success': False, 'error': 'No items provided'}), 400
removed = 0
for item in items:
category = (item or {}).get('category')
path = (item or {}).get('path')
if not path or os.path.isdir(path):
continue
if category == 'hidden':
set_path_hidden(path, False)
removed += 1
elif category == 'next_up':
set_path_queued(path, False)
removed += 1
elif category == 'recent_views':
remove_recent_views_for_path(path)
removed += 1
elif category == 'favorites':
set_path_favorited(path, False)
removed += 1
return jsonify({'success': True, 'removed': removed})
# Single-process personal app, so a plain dict + background thread is
# enough state for the duration prewarm - no job queue needed. Guarded by
# 'running' so a second click while one's in flight just reports progress
# on the existing run instead of starting a duplicate.
_duration_prewarm_state: Dict[str, Any] = {
'running': False,
'total': 0,
'done': 0,
'error': None,
}
def _run_duration_prewarm():
"""
Walk every course in the library and populate its persistent duration
cache (see _course_total_duration_seconds) and cover-art thumbnail
(see _generate_course_thumbnail) up front, so opening a course or
browsing the library for the first time doesn't pay either cost right
then - both were already paid here, once, in the background.
"""
global _duration_prewarm_state
course_dirs = get_all_course_dirs()
_duration_prewarm_state.update({
'running': True, 'total': len(course_dirs), 'done': 0, 'error': None,
})
try:
for course_dir in course_dirs:
media_files = [
f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
]
_course_total_duration_seconds(course_dir, media_files)
find_course_thumbnail(str(course_dir))
_duration_prewarm_state['done'] += 1
except Exception as e:
_duration_prewarm_state['error'] = str(e)
finally:
_duration_prewarm_state['running'] = False
# Durations just landed in the persistent per-course cache files,
# but _course_summary results computed before this run may still be
# sitting in the in-memory cache without them - drop everything so
# the next library view picks the fresh durations up immediately.
invalidate_cache()
@app.route('/api/library/prewarm-durations', methods=['POST'])
def prewarm_durations_api():
"""Kick off (or report on an already-running) background scan of every course's video/audio duration and cover art."""
if not _duration_prewarm_state['running']:
threading.Thread(target=_run_duration_prewarm, daemon=True).start()
return jsonify(_duration_prewarm_state)
@app.route('/api/library/prewarm-durations/status', methods=['GET'])
def prewarm_durations_status_api():
"""Poll the current progress of a duration prewarm run."""
return jsonify(_duration_prewarm_state)
# Same single-process-thread pattern as the duration prewarm above.
_thumbnail_regen_state: Dict[str, Any] = {
'running': False,
'total': 0,
'done': 0,
'error': None,
}
def _run_thumbnail_regen():
"""
Delete and regenerate every course's auto-generated thumbnail
(.offlineu_thumbnail.jpg) using _generate_course_thumbnail's current
logic - the only way to pick up an improved generation heuristic (e.g.
the title-card sampling added after thumbnails were already cached)
on courses whose thumbnail was generated before that change, since
find_course_thumbnail's normal fast path just keeps serving whatever
is already cached on disk forever. Only touches courses currently
using an auto-generated thumbnail - a manually-placed cover/folder/
thumbnail/thumb/poster file always wins and is never removed.
"""
global _thumbnail_regen_state
to_regen = [c for c in get_all_course_dirs() if (c / AUTO_THUMBNAIL_FILENAME).exists()]
_thumbnail_regen_state.update({
'running': True, 'total': len(to_regen), 'done': 0, 'error': None,
})
try:
for course_dir in to_regen:
try:
(course_dir / AUTO_THUMBNAIL_FILENAME).unlink()
except OSError:
pass
_generate_course_thumbnail(course_dir)
_thumbnail_regen_state['done'] += 1
except Exception as e:
_thumbnail_regen_state['error'] = str(e)
finally:
_thumbnail_regen_state['running'] = False
invalidate_cache()
@app.route('/api/library/regenerate-thumbnails', methods=['POST'])
def regenerate_thumbnails_api():
"""Kick off (or report on an already-running) background regeneration of every auto-generated course thumbnail."""
if not _thumbnail_regen_state['running']:
threading.Thread(target=_run_thumbnail_regen, daemon=True).start()
return jsonify(_thumbnail_regen_state)
@app.route('/api/library/regenerate-thumbnails/status', methods=['GET'])
def regenerate_thumbnails_status_api():
"""Poll the current progress of a thumbnail regeneration run."""
return jsonify(_thumbnail_regen_state)
@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/hidden-paths/bulk', methods=['POST'])
def bulk_set_hidden_paths_api():
"""Hide or un-hide several courses/directories at once."""
data = request.json or {}
paths = data.get('paths') or []
hidden = bool(data.get('hidden', True))
if not isinstance(paths, list) or not paths:
return jsonify({'error': 'paths is required'}), 400
library_root = os.path.abspath(get_library_root())
updated = get_hidden_paths()
for path in paths:
target = os.path.abspath(path)
if target == library_root or target.startswith(library_root + os.sep):
updated = set_path_hidden(target, hidden)
return jsonify({'success': True, 'hidden_paths': updated})
def validate_new_name(new_name: str) -> Optional[str]:
"""Validate a proposed directory name; returns an error message, or None if valid."""
if not new_name:
return 'New name cannot be empty'
if '/' in new_name or '\\' in new_name or '\x00' in new_name or new_name in ('.', '..'):
return 'New name cannot contain path separators'
if new_name.startswith('.'):
return 'New name cannot start with a dot'
return None
def perform_rename(old_path: str, new_name: str) -> Dict[str, Any]:
"""
Validate and apply a single directory rename within the library root:
rebases any hidden-path/recent-view references that pointed inside it,
and resets the active course if it (or an ancestor) was the thing
renamed. Shared by the single-item and bulk rename routes, always
returning a dict with a 'status' key for the caller to respond with.
"""
global current_course
error = validate_new_name(new_name)
if error:
return {'success': False, 'error': error, 'status': 400}
library_root = os.path.abspath(get_library_root())
old_abs = os.path.abspath(old_path)
if not (old_abs == library_root or old_abs.startswith(library_root + os.sep)):
return {'success': False, 'error': 'Path outside library root', 'status': 403}
if old_abs == library_root:
return {'success': False, 'error': 'Cannot rename the library root itself', 'status': 400}
if not os.path.isdir(old_abs):
return {'success': False, 'error': 'Directory not found', 'status': 404}
new_abs = os.path.join(os.path.dirname(old_abs), new_name)
if os.path.exists(new_abs):
return {'success': False, 'error': f'"{new_name}" already exists here', 'status': 409}
try:
os.rename(old_abs, new_abs)
except OSError as e:
return {'success': False, 'error': f'Rename failed: {e}', 'status': 500}
invalidate_cache() # renamed path invalidates any cached listing/summary/tree keyed by the old path
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 {
'success': True,
'new_path': new_abs,
'active_course_reset': active_course_reset,
'status': 200
}
@app.route('/api/rename-path', methods=['POST'])
def rename_path_api():
"""Rename a course/folder directory in the library, in place on disk."""
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
result = perform_rename(path, new_name)
if result.get('success'):
_record_undo_batch(f'Rename "{os.path.basename(path)}"',
[{'old_path': os.path.abspath(path), 'new_path': result['new_path']}])
status = result.pop('status')
return jsonify(result), status
def _iter_all_directories(directory: Path, hidden_set: set) -> Iterator[Path]:
"""
Yield every directory in the library tree - both course folders and the
category/group folders above them - skipping hidden ones and not
descending into a course's own internal sections (Section 1, etc. -
those aren't independently manageable library items anywhere else in
the app either, so bulk rename shouldn't touch them).
"""
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
yield entry
if not _looks_like_course(entry):
yield from _iter_all_directories(entry, hidden_set)
def _wildcard_to_regex(pattern: str) -> str:
"""
Translate a shell-style wildcard pattern into a regex fragment for
*substring* matching (not a full-name match, unlike fnmatch) - '*'
matches any run of characters, '?' matches exactly one, everything
else is matched literally. Lets ".BOOKWARE*" catch
".BOOKWARE-GETH", ".BOOKWARE-BOOKTIME", ".BOOKWARE-BLZiSO", etc. in
one pattern instead of one bulk rename per exact suffix.
"""
parts = []
for ch in pattern:
if ch == '*':
parts.append('.*')
elif ch == '?':
parts.append('.')
else:
parts.append(re.escape(ch))
return ''.join(parts)
def find_bulk_rename_matches(library_root: str, pattern: str, replacement: str,
mode: str = 'plain') -> List[Dict[str, str]]:
"""
Every directory (course or folder) in the library whose name matches
`pattern`, paired with what it would become. Three modes:
- 'plain' (default): `pattern` is matched/replaced as a literal
substring, same as before.
- 'wildcard': `pattern` uses shell-style '*'/'?' wildcards (see
_wildcard_to_regex); `replacement` is literal text.
- 'regex': `pattern` is a Python regex, matched with re.search and
replaced with re.sub - `replacement` may use backreferences like
\\1. Raises re.error if `pattern` isn't valid.
All three replace every match in the name, not just the first, mirroring
how plain mode's str.replace() already behaved.
"""
hidden_set = set(get_hidden_paths())
directories = _iter_all_directories(Path(library_root), hidden_set)
if mode == 'plain':
return [
{'path': str(directory), 'old_name': directory.name,
'new_name': directory.name.replace(pattern, replacement)}
for directory in directories
if pattern in directory.name
]
regex = re.compile(pattern if mode == 'regex' else _wildcard_to_regex(pattern))
matches = []
for directory in directories:
if regex.search(directory.name):
matches.append({
'path': str(directory),
'old_name': directory.name,
'new_name': regex.sub(replacement, directory.name),
})
return matches
@app.route('/api/bulk-rename/preview')
def bulk_rename_preview_api():
"""Preview a find/replace rename across the whole library, without touching disk."""
pattern = request.args.get('pattern', '')
replacement = request.args.get('replacement', '')
mode = request.args.get('mode', 'plain')
if not pattern:
return jsonify({'error': 'pattern is required'}), 400
if mode not in ('plain', 'wildcard', 'regex'):
return jsonify({'error': 'Invalid mode'}), 400
library_root = os.path.abspath(get_library_root())
try:
matches = find_bulk_rename_matches(library_root, pattern, replacement, mode)
except re.error as e:
return jsonify({'error': f'Invalid pattern: {e}'}), 400
return jsonify({'matches': matches})
@app.route('/api/bulk-rename/apply', methods=['POST'])
def bulk_rename_apply_api():
"""
Apply a bulk rename. Takes the *exact* {path, new_name} list the client
got back from /api/bulk-rename/preview, rather than re-deriving matches
from the pattern again - what gets renamed is provably what the user
saw and approved, and can't drift if the library changed in between.
Continues past individual failures (e.g. a collision) instead of
aborting the whole batch, reporting a per-item result.
"""
data = request.json or {}
items = data.get('items') or []
if not isinstance(items, list) or not items:
return jsonify({'error': 'items is required'}), 400
results = []
undo_entries = []
for item in items:
path = item.get('path', '')
new_name = item.get('new_name', '')
outcome = perform_rename(path, new_name)
outcome.pop('status', None)
outcome['path'] = path
outcome['old_name'] = item.get('old_name', '')
outcome['new_name'] = new_name
if outcome.get('success'):
undo_entries.append({'old_path': os.path.abspath(path), 'new_path': outcome['new_path']})
results.append(outcome)
succeeded = len(undo_entries)
_record_undo_batch(f'Bulk rename ({succeeded} item{"" if succeeded == 1 else "s"})', undo_entries)
return jsonify({'results': results})
@app.route('/settings')
def settings_page():
"""Render the display-settings page."""
current = load_settings()
# Grouped for the dropdown as <optgroup>s: the two base themes, the
# original hand-picked editor-inspired presets, then the Figma-derived
# website-scheme themes under their own source-page categories (see
# WEBSITE_SCHEME_CATEGORIES) - a flat 65-option list would be unusable.
website_scheme_keys = {k for keys in WEBSITE_SCHEME_CATEGORIES.values() for k in keys}
editor_presets = sorted(
SETTINGS_CHOICES['theme'] - {'dark', 'light'} - website_scheme_keys,
key=lambda t: THEME_DISPLAY_NAMES[t]
)
theme_groups = [('Base', ['dark', 'light']), ('Editor Themes', editor_presets)]
for category, keys in WEBSITE_SCHEME_CATEGORIES.items():
theme_groups.append((category, sorted(keys, key=lambda t: THEME_DISPLAY_NAMES[t])))
return render_template(
'settings.html',
settings=current,
default_library_path=LIBRARY_PATH,
theme_groups=theme_groups,
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'],
active_tab='settings',
build_version=BUILD_VERSION,
changelog_entries=load_changelog_entries(),
)
@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)
})
# Shared between download_backup and restore_backup so the two can never
# drift out of sync with each other about what belongs at the archive root.
BACKUP_ROOT_FILES = {
'settings.json': SETTINGS_FILE,
'hidden_paths.json': HIDDEN_PATHS_FILE,
'next_up.json': NEXT_UP_FILE,
'favorites.json': FAVORITES_FILE,
'recent_views.json': RECENT_VIEWS_FILE,
'outline_config.json': OUTLINE_CONFIG_FILE,
'ignored_duplicates.json': IGNORED_DUPLICATES_FILE,
}
@app.route('/api/backup')
def download_backup():
"""
Bundle everything that isn't recoverable from the course files
themselves - settings, hidden-path curation, the Next Up queue,
recently-viewed history, Outline config, and every course's
progress/notes file - into a single downloadable zip. Cheap insurance
before a NAS migration or a docker volume mistake; see restore_backup
for the other half of this round trip.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for name, path in BACKUP_ROOT_FILES.items():
if os.path.exists(path):
zf.write(path, name)
library_root = get_library_root()
for course_dir in get_all_course_dirs():
progress_file = course_dir / '.offlineu_progress.json'
if progress_file.exists():
relative = os.path.relpath(str(course_dir), library_root)
zf.write(progress_file, os.path.join('progress', relative, '.offlineu_progress.json'))
buffer.seek(0)
filename = f"offlineu-backup-{datetime.now().strftime('%Y-%m-%d')}.zip"
return send_file(buffer, mimetype='application/zip', as_attachment=True, download_name=filename)
@app.route('/api/backup/restore', methods=['POST'])
def restore_backup():
"""
Restore a zip produced by download_backup: the root-level app files in
BACKUP_ROOT_FILES, plus each course's progress file from
progress/<relative-course-path>/.offlineu_progress.json. Overwrites
whatever's currently there - this is a deliberate "put my data back"
action, not a merge.
"""
uploaded = request.files.get('backup')
if not uploaded or not uploaded.filename:
return jsonify({'success': False, 'error': 'No backup file selected'}), 400
try:
zf = zipfile.ZipFile(io.BytesIO(uploaded.read()))
except zipfile.BadZipFile:
return jsonify({'success': False, 'error': 'That file is not a valid zip archive'}), 400
os.makedirs(DATA_DIR, exist_ok=True)
names = set(zf.namelist())
restored = []
skipped = []
for archive_name, dest_path in BACKUP_ROOT_FILES.items():
if archive_name not in names:
continue
with zf.open(archive_name) as src, open(dest_path, 'wb') as dst:
dst.write(src.read())
restored.append(archive_name)
library_root = os.path.abspath(get_library_root())
for name in names:
if not (name.startswith('progress/') and name.endswith('/.offlineu_progress.json')):
continue
relative = name[len('progress/'):-len('/.offlineu_progress.json')]
dest_dir = os.path.abspath(os.path.join(library_root, relative))
# Guard against a zip entry trying to write outside the library
# root (zip-slip) - restore is a trusted, deliberate admin action,
# but the archive's paths themselves shouldn't be trusted blindly.
if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
skipped.append(relative)
continue
if not os.path.isdir(dest_dir):
skipped.append(relative)
continue
with zf.open(name) as src, open(os.path.join(dest_dir, '.offlineu_progress.json'), 'wb') as dst:
dst.write(src.read())
restored.append(name)
invalidate_cache()
return jsonify({'success': True, 'restored': len(restored), 'skipped': skipped})
@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 = get_course_tree(course_path)
return jsonify({'success': True, 'course_name': current_course.name})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/random-pick')
def random_pick():
""""Surprise Me" - load a random not-yet-fully-watched course and land on its dashboard."""
global current_course
course_dir = get_random_incomplete_course()
if not course_dir:
return redirect(url_for('index'))
current_course = get_course_tree(str(course_dir))
return redirect(url_for('index'))
@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', '')
seek_seconds = request.args.get('t', '')
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 = get_course_tree(course_path)
except Exception as e:
print(f"Could not load course for recent view: {e}")
return redirect(url_for('index'))
lesson_url = url_for('view_lesson', lesson_path=lesson_path)
if seek_seconds:
lesson_url = f"{lesson_url}?t={seek_seconds}"
return redirect(lesson_url)
@app.route('/help')
def help_page():
"""Static help page: how to use the app, supported file types."""
return render_template('help.html', active_tab='help')
@app.route('/notes')
def notes_hub():
"""Every lesson note across the whole library in one place."""
return render_template('notes_hub.html', notes=get_all_notes(), active_tab='notes')
@app.route('/unsorted')
def unsorted_page():
"""Review page for filing new courses out of the Unsorted folder - see /api/unsorted/scan for the actual proposals."""
return render_template('unsorted.html', active_tab='files')
@app.route('/library/search-transcripts')
def search_transcripts_api():
"""Search inside lesson subtitle files across the library."""
query = request.args.get('q', '').strip()
if not query:
return jsonify({'results': []})
return jsonify({'results': search_transcripts(query)})
@app.route('/course/study-guide')
def download_study_guide():
"""Download every note written for the currently loaded course as one markdown file."""
global current_course
if not current_course:
return "No course loaded", 404
markdown = build_study_guide_markdown(current_course)
buffer = io.BytesIO(markdown.encode('utf-8'))
safe_name = re.sub(r'[^\w\-. ]', '_', current_course.name).strip() or 'course'
return send_file(buffer, mimetype='text/markdown', as_attachment=True,
download_name=f"{safe_name} - Study Guide.md")
@app.route('/api/next-up', methods=['POST'])
def set_next_up_api():
"""Add or remove a course from the Next Up queue."""
data = request.json or {}
path = data.get('path', '')
queued = bool(data.get('queued', 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_queued(target, queued)
return jsonify({'success': True, 'next_up': updated})
@app.route('/api/favorites', methods=['POST'])
def set_favorite_api():
"""Add or remove a course from favorites."""
data = request.json or {}
path = data.get('path', '')
favorited = bool(data.get('favorited', 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_favorited(target, favorited)
return jsonify({'success': True, 'favorites': updated})
@app.route('/api/next-up/bulk', methods=['POST'])
def bulk_set_next_up_api():
"""Add several courses to the Next Up queue at once."""
data = request.json or {}
paths = data.get('paths') or []
if not isinstance(paths, list) or not paths:
return jsonify({'error': 'paths is required'}), 400
library_root = os.path.abspath(get_library_root())
updated = get_next_up_paths()
for path in paths:
target = os.path.abspath(path)
if target == library_root or target.startswith(library_root + os.sep):
updated = set_path_queued(target, True)
return jsonify({'success': True, 'next_up': updated})
@app.route('/api/next-up/reorder', methods=['POST'])
def reorder_next_up_api():
"""Replace the Next Up order wholesale, from the client's up/down-reordered list."""
data = request.json or {}
paths = data.get('paths')
if not isinstance(paths, list):
return jsonify({'error': 'paths must be a list'}), 400
updated = reorder_next_up(paths)
return jsonify({'success': True, 'next_up': updated})
@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, and the section (DirectoryNode) it belongs to
lesson, section_node = 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 without touching saved progress/completed state
ProgressTracker.touch_lesson_accessed(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 notes and progress 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.
lesson_progress = ProgressTracker.load_progress(current_course).get(lesson_path, {})
seek_seconds = request.args.get('t', type=int)
return render_template('lesson_view.html',
course=current_course,
lesson=lesson,
lesson_path=lesson_path,
lesson_notes=ProgressTracker._notes_from_entry(lesson_progress),
lesson_progress_seconds=lesson_progress.get('progress_seconds', 0),
initial_seek_seconds=seek_seconds,
outline_topic_id=lesson_progress.get('outline_topic_id', ''),
outline_topic_name=lesson_progress.get('outline_topic_name', ''),
section_lessons=get_section_lessons(current_course, section_node, lesson),
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) -> Tuple[Optional[Lesson], Optional[DirectoryNode]]:
"""
Find a lesson in the tree by path, along with the DirectoryNode that
directly owns it - DirectoryNode has no parent pointer, so this is the
only way to get "the section this lesson belongs to" (its `.lessons`
list is exactly that section's sibling set, used for the lesson page's
"up next in this section" list).
"""
# 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, node
# Recursively search children
for child in node.children.values():
result_lesson, result_node = find_lesson_in_tree(child, target_path)
if result_lesson:
return result_lesson, result_node
return None, None
def get_section_lessons(course: Course, section_node: DirectoryNode, current_lesson: Lesson) -> List[Dict[str, Any]]:
"""
The sibling lessons in current_lesson's own section, for the lesson
page's "up next in this section" list. view_lesson() isn't on the
apply_progress_to_tree() code path, so - like that route already does
for the current lesson's own notes/progress - this reads progress
directly from the progress file rather than relying on Lesson fields.
"""
progress = ProgressTracker.load_progress(course)
siblings = []
for sibling in section_node.lessons:
key = _resolve_lesson_progress_key(course, sibling, progress)
entry = progress.get(key, {}) if key else {}
completed = entry.get('completed', False)
progress_seconds = entry.get('progress_seconds', 0)
duration_seconds = entry.get('duration_seconds', 0)
percent_watched = 100 if completed else (
round(100 * progress_seconds / duration_seconds) if duration_seconds and progress_seconds else 0
)
siblings.append({
'title': sibling.title,
'url': get_lesson_url(sibling, course.path),
'lesson_type': sibling.lesson_type,
'completed': completed,
'percent_watched': percent_watched,
'is_current': sibling is current_lesson,
})
return siblings
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/add', methods=['POST'])
def add_lesson_note_api():
"""Append a new timestamped note to a lesson."""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
text = (data.get('text') or '').strip()
if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400
if not text:
return jsonify({'error': 'text is required'}), 400
timestamp_seconds = data.get('timestamp_seconds')
if timestamp_seconds is not None:
try:
timestamp_seconds = int(timestamp_seconds)
except (TypeError, ValueError):
timestamp_seconds = None
notes = ProgressTracker.add_lesson_note(current_course, lesson_path, text, timestamp_seconds)
return jsonify({'success': True, 'notes': notes})
@app.route('/api/lesson-note/update', methods=['POST'])
def update_lesson_note_api():
"""Edit one of a lesson's timestamped notes."""
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_id = data.get('note_id')
text = (data.get('text') or '').strip()
if not lesson_path or not note_id:
return jsonify({'error': 'lesson_path and note_id are required'}), 400
if not text:
return jsonify({'error': 'text is required'}), 400
notes = ProgressTracker.update_lesson_note_text(current_course, lesson_path, note_id, text)
return jsonify({'success': True, 'notes': notes})
@app.route('/api/lesson-note/delete', methods=['POST'])
def delete_lesson_note_api():
"""Remove one of a lesson's timestamped notes."""
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_id = data.get('note_id')
if not lesson_path or not note_id:
return jsonify({'error': 'lesson_path and note_id are required'}), 400
notes = ProgressTracker.delete_lesson_note(current_course, lesson_path, note_id)
return jsonify({'success': True, 'notes': notes})
@app.route('/api/lesson-note/topic', methods=['POST'])
def update_lesson_note_topic_api():
"""Remember which Outline topic (collection) a lesson's note should push to."""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400
topic_id = (data.get('topic_id') or '').strip()
topic_name = (data.get('topic_name') or '').strip()
ProgressTracker.set_lesson_outline_topic(current_course, lesson_path, topic_id, topic_name)
return jsonify({'success': True})
@app.route('/api/outline/config', methods=['GET'])
def get_outline_config_api():
"""Whether Outline is configured, the (non-secret) base URL, and the default collection - never the token."""
config = load_outline_config()
return jsonify({
'configured': bool(config['base_url'] and config['api_token']),
'base_url': config['base_url'],
'default_collection_id': config['default_collection_id'],
'default_collection_name': config['default_collection_name']
})
@app.route('/api/outline/config', methods=['POST'])
def set_outline_config_api():
"""Save the Outline base URL / API token / default collection."""
data = request.json or {}
base_url = (data.get('base_url') or '').strip()
api_token = (data.get('api_token') or '').strip()
if not base_url:
return jsonify({'error': 'Outline URL is required'}), 400
save_outline_config(
base_url, api_token,
default_collection_id=data.get('default_collection_id'),
default_collection_name=data.get('default_collection_name')
)
return jsonify({'success': True})
@app.route('/api/outline/test')
def test_outline_connection_api():
"""Ping Outline with the stored config, for the Settings page's 'Test Connection' button."""
try:
_outline_request('collections.list', {'limit': 1})
return jsonify({'success': True})
except RuntimeError as e:
return jsonify({'success': False, 'error': str(e)}), 502
@app.route('/api/outline/collections')
def list_outline_collections_api():
"""Every Outline collection - used by the Settings page's default-collection picker."""
try:
result = _outline_request('collections.list', {'limit': 100})
except RuntimeError as e:
return jsonify({'collections': [], 'error': str(e)})
collections = [{'id': c['id'], 'name': c['name']} for c in result.get('data', [])]
return jsonify({'collections': collections})
@app.route('/api/outline/topics')
def list_outline_topics_api():
"""The lesson page's topic chooser option list - top-level documents in the configured default collection."""
try:
topics = list_outline_topics()
except RuntimeError as e:
return jsonify({'topics': [], 'error': str(e)})
return jsonify({'topics': topics})
@app.route('/api/outline/resolve-topic', methods=['POST'])
def resolve_outline_topic_api():
"""
Resolve a freshly-typed topic name to a real Outline document id right
away (find-or-create), rather than deferring to the fire-and-forget
pagehide push - see push_note_to_outline's docstring for why that
matters (a page that fires pagehide more than once, e.g. via bfcache,
would otherwise send the same 'create a new topic' intent repeatedly).
"""
data = request.json or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'name is required'}), 400
try:
topic = resolve_outline_topic(name)
except RuntimeError as e:
return jsonify({'error': str(e)}), 502
return jsonify({'success': True, 'id': topic['id'], 'name': topic['name']})
@app.route('/api/outline/push', methods=['POST'])
def push_outline_note_api():
"""
Push a lesson's note to Outline. Called via navigator.sendBeacon() on
pagehide, so this has no interactive caller to report errors back to in
the common case - failures are logged server-side and otherwise silent,
matching the fire-and-forget nature of the trigger.
"""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
lesson_title = data.get('lesson_title', lesson_path)
topic_id = (data.get('topic_id') or '').strip()
new_topic_name = (data.get('new_topic_name') or '').strip()
if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400
try:
result = push_note_to_outline(current_course, lesson_path, lesson_title, topic_id, new_topic_name)
except RuntimeError as e:
print(f"Outline push failed: {e}")
return jsonify({'success': False, 'error': str(e)}), 502
if not result['success']:
print(f"Outline push skipped: {result['error']}")
return jsonify(result)
@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", "version": BUILD_VERSION}), 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)