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:
+170
-22
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user