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:
+101
-37
@@ -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'))
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user