Add Favorites, storage drill-down, keyboard shortcuts, and What's New

- Favorites: star toggle on course rows, the course page, and a new
  dashboard card; rebased on rename/move, backed up/restored, and
  caught by stale-reference cleanup (also fixed a pre-existing gap
  where favorites.json wasn't in the backup file list).
- Storage Usage: click a folder row to drill into its contents one
  level at a time, with a Back button.
- Lesson player: added ,/. (speed step), [/] (prev/next lesson), and
  F (fullscreen) keyboard shortcuts.
- Settings: a CHANGELOG.md-backed "What's New" list, since VERSION
  alone only ever shows the current build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 19:23:40 -04:00
co-authored by Claude Sonnet 5
parent 1fbf0c9312
commit fc83876abe
10 changed files with 404 additions and 39 deletions
+170 -22
View File
@@ -57,6 +57,25 @@ def _load_build_version() -> str:
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'}
@@ -1052,6 +1071,51 @@ def get_next_up_courses() -> List[Dict[str, Any]]:
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:
@@ -1083,6 +1147,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None:
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:
@@ -1145,6 +1214,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
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}"]}
@@ -1166,6 +1236,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
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:
@@ -1753,7 +1824,7 @@ def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
/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': []}
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):
@@ -1763,6 +1834,10 @@ def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
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', '')
@@ -1945,11 +2020,13 @@ def search_library_courses(query: str) -> List[Dict[str, Any]]:
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
@@ -2897,6 +2974,7 @@ def index():
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
@@ -2917,6 +2995,7 @@ def index():
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')
@@ -3280,34 +3359,80 @@ def _format_bytes(n: int) -> str:
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 per top-level library folder (category or a bare course
sitting directly at the root), sorted largest-first, for spotting
what's eating the most space on the NAS. Manually triggered like
Duplicate Courses - a full recursive size scan touches every file in
the library, too expensive to run automatically on page load.
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())
try:
top_level = sorted(
(p for p in library_root.iterdir() if p.is_dir() and not p.name.startswith('.')),
key=lambda p: p.name.lower()
)
except (PermissionError, OSError) as e:
return jsonify({'error': str(e)}), 500
library_root = Path(get_library_root()).resolve()
subpath = (request.args.get('path') or '').strip()
entries = []
for entry in top_level:
if entry.name == UNSORTED_FOLDER_NAME:
continue
size = _directory_size_bytes(entry)
entries.append({'name': entry.name, 'path': str(entry), 'bytes': size, 'human': _format_bytes(size)})
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
entries.sort(key=lambda e: e['bytes'], reverse=True)
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)})
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')
@@ -3658,6 +3783,9 @@ def clean_stale_references_api():
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})
@@ -4015,6 +4143,7 @@ def settings_page():
corner_radius_choices=['sharp', 'rounded', 'pill'],
active_tab='settings',
build_version=BUILD_VERSION,
changelog_entries=load_changelog_entries(),
)
@@ -4062,6 +4191,7 @@ 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,
@@ -4265,6 +4395,24 @@ def set_next_up_api():
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."""