Cache library filesystem scans to fix ~90s NAS page loads

There was no caching anywhere in the app, so every request re-walked
the SMB-mounted library from scratch - the dashboard alone triggered
2 duplicate full-library directory walks, 100+ individual
.offlineu_progress.json opens, and ~10-15 full per-course recursive
rglob() scans, none of it shared between requests. Measured ~90s per
dashboard load against the real mounted NAS, with a second immediate
reload taking just as long - proof nothing was being reused.

Add a minimal in-process TTL cache (5 min) and apply it at the actual
hot spots: the shared course-directory listing (get_all_course_dirs,
replacing 6 independent iter_all_courses() walks), the per-course
media-count/thumbnail summary (_course_summary, also now reused by
list_library_directory instead of a third duplicate implementation),
the per-course tree scan (get_course_tree - safe to cache since
progress is always re-applied fresh on top, never baked into the
cached structure), and the transcript-search subtitle index (the
worst offender - previously re-read and lowercased every subtitle
file in the library on every search).

Invalidates immediately on hide/show, rename, and library-path
changes; a "Refresh Library" button on Settings covers files added
directly on the NAS outside the app.

Measured after the fix, same real NAS mount: dashboard ~90s -> 1.3s
warm, Notes Hub ~1.15s warm, Library browser ~3.6s warm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 14:27:24 -04:00
co-authored by Claude Sonnet 5
parent bc6725ee4c
commit 2941910c54
2 changed files with 135 additions and 49 deletions
+101 -37
View File
@@ -11,6 +11,7 @@ import re
import sys import sys
import argparse import argparse
import io import io
import time
import uuid import uuid
import zipfile import zipfile
import urllib.request import urllib.request
@@ -39,6 +40,32 @@ THUMBNAIL_BASENAMES = ('cover', 'folder', 'thumbnail', 'thumb', 'poster')
# COURSES_LIBRARY_PATH env var. # COURSES_LIBRARY_PATH env var.
LIBRARY_PATH = os.environ.get('COURSES_LIBRARY_PATH', '/app/courses') 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
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 # App-wide (non per-course) persisted data, e.g. display settings. Matches
# the ./data volume mount in docker-compose.yml. # the ./data volume mount in docker-compose.yml.
DATA_DIR = os.environ.get('OFFLINEU_DATA_DIR', '/app/data') DATA_DIR = os.environ.get('OFFLINEU_DATA_DIR', '/app/data')
@@ -246,6 +273,8 @@ def save_settings(new_settings: Dict[str, Any]) -> Dict[str, Any]:
os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, 'w') as f: with open(SETTINGS_FILE, 'w') as f:
json.dump(current, f, indent=2) json.dump(current, f, indent=2)
if 'library_path' in new_settings:
invalidate_cache() # the library root itself changed
return current return current
@@ -503,6 +532,17 @@ class DynamicCourseParser:
app.jinja_env.globals['section_stats'] = DynamicCourseParser._calculate_completion_stats 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: def _has_direct_media(directory: Path) -> bool:
"""Check whether a directory contains media files directly (not recursively)""" """Check whether a directory contains media files directly (not recursively)"""
try: try:
@@ -600,6 +640,7 @@ def set_path_hidden(path: str, hidden: bool) -> List[str]:
os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True)
with open(HIDDEN_PATHS_FILE, 'w') as f: with open(HIDDEN_PATHS_FILE, 'w') as f:
json.dump(result, f, indent=2) json.dump(result, f, indent=2)
invalidate_cache() # hidden set affects which courses get_all_course_dirs() yields
return result return result
@@ -762,18 +803,9 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
continue continue
if _looks_like_course(entry): if _looks_like_course(entry):
media_count = len([ item = _course_summary(entry)
f for f in entry.rglob('*') item['hidden'] = is_hidden
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS items.append(item)
])
items.append({
'type': 'course',
'name': entry.name,
'path': entry_path_str,
'media_files': media_count,
'hidden': is_hidden,
'has_thumbnail': find_course_thumbnail(entry_path_str) is not None
})
else: else:
if skip_hidden: if skip_hidden:
# Only count courses that aren't themselves curated out - # Only count courses that aren't themselves curated out -
@@ -841,9 +873,21 @@ def iter_all_courses(dir_path: str) -> Iterator[Path]:
yield from walk(Path(dir_path)) 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())))
def _course_summary(course_dir: Path) -> Dict[str, Any]: def _course_summary(course_dir: Path) -> Dict[str, Any]:
"""Build the {type, name, path, media_files, hidden, has_thumbnail} shape """Build the {type, name, path, media_files, hidden, has_thumbnail} shape
shared by the Library browser, search results, and Recently Added.""" shared by the Library browser, search results, and Recently Added."""
def compute():
media_count = len([ media_count = len([
f for f in course_dir.rglob('*') f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
@@ -856,19 +900,20 @@ def _course_summary(course_dir: Path) -> Dict[str, Any]:
'hidden': False, 'hidden': False,
'has_thumbnail': find_course_thumbnail(str(course_dir)) is not None 'has_thumbnail': find_course_thumbnail(str(course_dir)) is not None
} }
return dict(cache_get_or_compute(f'course_summary:{course_dir}', compute))
def search_library_courses(dir_path: str, query: str) -> List[Dict[str, Any]]: def search_library_courses(query: str) -> List[Dict[str, Any]]:
""" """
Search the library for courses whose name contains `query` Search the library for courses whose name contains `query`
(case-insensitive). The expensive per-course lookups (media_count, (case-insensitive). The expensive per-course lookups (media_count,
thumbnail) only run for courses whose name actually matches - see thumbnail) only run for courses whose name actually matches - see
iter_all_courses. get_all_course_dirs.
""" """
query_lower = query.lower() query_lower = query.lower()
return [ return [
_course_summary(course) _course_summary(course)
for course in iter_all_courses(dir_path) for course in get_all_course_dirs()
if query_lower in course.name.lower() if query_lower in course.name.lower()
] ]
@@ -891,14 +936,32 @@ def _extract_subtitle_snippet(text: str, query_lower: str, context_chars: int =
return ('' if start > 0 else '') + snippet + ('' if end < len(text) else '') 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]]: def search_transcripts(query: str, limit: int = 30) -> List[Dict[str, Any]]:
""" """
Search inside lesson subtitle files (.srt/.vtt/etc.) for `query`, Search inside lesson subtitle files (.srt/.vtt/etc.) for `query`,
case-insensitive. Unlike course-name search, subtitles live inside each case-insensitive - see _course_subtitle_index for how the expensive
course's own directory tree rather than at the course boundary, so this per-course file reads are cached and shared across queries.
needs an actual per-course file walk - kept cheap by doing a raw
substring check as the filter step and only extracting a cleaned-up
snippet for files that actually match.
Subtitle files aren't wired into the Lesson tree today (see Subtitle files aren't wired into the Lesson tree today (see
DynamicCourseParser._create_lesson_from_file - a subtitle file never DynamicCourseParser._create_lesson_from_file - a subtitle file never
@@ -910,17 +973,11 @@ def search_transcripts(query: str, limit: int = 30) -> List[Dict[str, Any]]:
query_lower = query.lower() query_lower = query.lower()
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
for course_dir in iter_all_courses(get_library_root()): for course_dir in get_all_course_dirs():
for subtitle_file in course_dir.rglob('*'): for subtitle_file, text, text_lower in _course_subtitle_index(course_dir):
if len(results) >= limit: if len(results) >= limit:
return results return results
if not subtitle_file.is_file() or subtitle_file.suffix.lower() not in SUBTITLE_EXTENSIONS: if query_lower not in text_lower:
continue
try:
text = subtitle_file.read_text(encoding='utf-8', errors='ignore')
except OSError:
continue
if query_lower not in text.lower():
continue continue
media_match = next( media_match = next(
@@ -963,9 +1020,8 @@ def get_recently_added_courses(limit: int = 5) -> List[Dict[str, Any]]:
the dashboard's "Recently Added" card - separate from Recently Viewed the dashboard's "Recently Added" card - separate from Recently Viewed
(which tracks what you've *watched*, not what showed up in the library). (which tracks what you've *watched*, not what showed up in the library).
""" """
library_root = get_library_root()
dated = [] dated = []
for course_dir in iter_all_courses(library_root): for course_dir in get_all_course_dirs():
try: try:
mtime = course_dir.stat().st_mtime mtime = course_dir.stat().st_mtime
except OSError: except OSError:
@@ -1000,7 +1056,7 @@ def _scan_library_activity() -> Dict[str, Any]:
courses: List[Dict[str, Any]] = [] courses: List[Dict[str, Any]] = []
heatmap_cutoff = datetime.now().date() - timedelta(days=89) heatmap_cutoff = datetime.now().date() - timedelta(days=89)
for course_dir in iter_all_courses(get_library_root()): for course_dir in get_all_course_dirs():
total_courses += 1 total_courses += 1
try: try:
with open(course_dir / '.offlineu_progress.json', 'r') as f: with open(course_dir / '.offlineu_progress.json', 'r') as f:
@@ -1642,7 +1698,7 @@ def get_all_notes() -> List[Dict[str, Any]]:
lesson with several notes contributes one row per note. lesson with several notes contributes one row per note.
""" """
notes = [] notes = []
for course_dir in iter_all_courses(get_library_root()): for course_dir in get_all_course_dirs():
try: try:
with open(course_dir / '.offlineu_progress.json', 'r') as f: with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f) progress = json.load(f)
@@ -1939,10 +1995,17 @@ def library_search():
if not query: if not query:
return jsonify({'library_path': library_root, 'results': []}) return jsonify({'library_path': library_root, 'results': []})
results = search_library_courses(library_root, query) results = search_library_courses(query)
return jsonify({'library_path': library_root, 'results': results}) 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/hidden-paths', methods=['GET']) @app.route('/api/hidden-paths', methods=['GET'])
def get_hidden_paths_api(): def get_hidden_paths_api():
"""List currently-hidden course/directory paths, with display names.""" """List currently-hidden course/directory paths, with display names."""
@@ -2017,6 +2080,7 @@ def perform_rename(old_path: str, new_name: str) -> Dict[str, Any]:
except OSError as e: except OSError as e:
return {'success': False, 'error': f'Rename failed: {e}', 'status': 500} 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) rebase_library_path(old_abs, new_abs)
active_course_reset = False active_course_reset = False
@@ -2209,7 +2273,7 @@ def download_backup():
zf.write(path, name) zf.write(path, name)
library_root = get_library_root() library_root = get_library_root()
for course_dir in iter_all_courses(library_root): for course_dir in get_all_course_dirs():
progress_file = course_dir / '.offlineu_progress.json' progress_file = course_dir / '.offlineu_progress.json'
if progress_file.exists(): if progress_file.exists():
relative = os.path.relpath(str(course_dir), library_root) relative = os.path.relpath(str(course_dir), library_root)
@@ -2232,7 +2296,7 @@ def load_course():
return jsonify({'error': 'Invalid course path'}), 400 return jsonify({'error': 'Invalid course path'}), 400
try: try:
current_course = DynamicCourseParser.scan_directory(course_path) current_course = get_course_tree(course_path)
return jsonify({'success': True, 'course_name': current_course.name}) return jsonify({'success': True, 'course_name': current_course.name})
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@@ -2255,7 +2319,7 @@ def open_recent():
try: try:
if not current_course or current_course.path != course_path: if not current_course or current_course.path != course_path:
current_course = DynamicCourseParser.scan_directory(course_path) current_course = get_course_tree(course_path)
except Exception as e: except Exception as e:
print(f"Could not load course for recent view: {e}") print(f"Could not load course for recent view: {e}")
return redirect(url_for('index')) return redirect(url_for('index'))
+22
View File
@@ -432,6 +432,11 @@
<button class="btn" onclick="saveLibraryPath()">Save</button> <button class="btn" onclick="saveLibraryPath()">Save</button>
</div> </div>
<span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span> <span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
<div style="display: flex; align-items: center; gap: 10px;">
<button class="btn btn-secondary btn-sm" onclick="refreshLibraryCache()">🔄 Refresh Library</button>
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
<span class="setting-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</span>
</div> </div>
</div> </div>
@@ -951,6 +956,23 @@
}); });
} }
function refreshLibraryCache() {
const status = document.getElementById('library-refresh-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Refreshing…';
fetch('/api/library/refresh', { method: 'POST' })
.then(r => r.json())
.then(data => {
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
status.textContent = data.success ? '✓ Refreshed' : 'Could not refresh';
if (data.success) setTimeout(() => { status.textContent = ''; }, 3000);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
function loadCourseFromPath() { function loadCourseFromPath() {
const input = document.getElementById('manual-course-path'); const input = document.getElementById('manual-course-path');
const status = document.getElementById('manual-course-status'); const status = document.getElementById('manual-course-status');