Fix wrapped-course false positive in picker; add build version display

Sort Unsorted's destination picker still listed a course whose real
video content sat two levels deep inside its own "display name"
folder (a common release-bundle extraction shape), alongside empty
leftover folders from the extraction that neither of the two earlier
exclusion checks could safely rule out - an empty folder is
indistinguishable from a legitimate freshly-created category. Detect
this case by comparing token similarity between a folder's own name
and its single course child's name: high overlap means they're naming
the same thing (a redundant wrapper), not a deliberate category
holding one course.

Also bake the build's git commit + build time into the Docker image
(git rev-parse at build time, .git deleted again immediately after) so
a push's effect on the running container is actually visible - shown
on the Settings page and in /health - instead of having to guess
whether a webhook rebuild picked up the latest commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 13:52:32 -04:00
co-authored by Claude Sonnet 5
parent 7fbe63e05c
commit 8d1ce72372
5 changed files with 111 additions and 16 deletions
+74 -7
View File
@@ -30,6 +30,32 @@ from flask import Flask, render_template, request, jsonify, send_file, redirect,
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-change-in-production'
def _load_build_version() -> str:
"""
Commit + build time baked into the image at build time (see
Dockerfile) - lets Settings and /health show which version is
actually running, the only way to confirm a git push made it into a
rebuilt container, since the webhook does a full image rebuild with
no other visible confirmation. Falls back to 'dev' outside Docker,
where there's no BUILD_VERSION file (e.g. running via --library-path
directly).
"""
version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'BUILD_VERSION')
try:
with open(version_file, 'r') as f:
lines = [line.strip() for line in f if line.strip()]
if len(lines) >= 2:
return f'{lines[0]} · built {lines[1]}'
if lines:
return lines[0]
except OSError:
pass
return 'dev'
BUILD_VERSION = _load_build_version()
# Supported file types
VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'}
AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'}
@@ -1324,6 +1350,41 @@ def _is_media_free_subtree(directory: Path) -> bool:
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,
@@ -1345,12 +1406,16 @@ def _build_category_index() -> List[Dict[str, Any]]:
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), or a subtree with no
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.), 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.
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.
"""
library_root = Path(get_library_root())
try:
@@ -1373,7 +1438,8 @@ def _build_category_index() -> List[Dict[str, Any]]:
continue
except OSError:
pass
if _looks_like_course(entry) or _is_unrecognized_leaf_item(entry) or _is_media_free_subtree(entry):
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()
@@ -3633,6 +3699,7 @@ def settings_page():
card_style_choices=['flat', 'elevated', 'bordered'],
corner_radius_choices=['sharp', 'rounded', 'pill'],
active_tab='settings',
build_version=BUILD_VERSION,
)
@@ -4365,7 +4432,7 @@ def serve_file(filepath):
@app.route('/health')
def healthcheck():
"""Healthcheck endpoint for Docker"""
return jsonify({"status": "healthy"}), 200
return jsonify({"status": "healthy", "version": BUILD_VERSION}), 200
@app.route('/reset_course')
def reset_course():