From 8d1ce723721911f922ae7cdd89d91183b9e7c59d Mon Sep 17 00:00:00 2001 From: rmsitz Date: Mon, 24 Aug 2026 13:52:32 -0400 Subject: [PATCH] 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 --- .dockerignore | 5 ++- Dockerfile | 14 ++++++- README.md | 23 +++++++++--- offlineu_core.py | 81 +++++++++++++++++++++++++++++++++++++---- templates/settings.html | 4 ++ 5 files changed, 111 insertions(+), 16 deletions(-) diff --git a/.dockerignore b/.dockerignore index 14e031a..98084ce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ # Version control -.git +# .git is intentionally NOT excluded - the Dockerfile reads it at build +# time (git rev-parse) to bake the running commit into the image as +# /app/BUILD_VERSION, then deletes .git itself in that same build step so +# it never ends up in the final image. .gitignore .github diff --git a/Dockerfile b/Dockerfile index 069aa8e..9d2a461 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,9 @@ FROM python:3.13.5-slim-bookworm WORKDIR /app # ffprobe (from ffmpeg) reads each video's duration for the library -# browser's "how long is this course" display -RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ +# browser's "how long is this course" display; git is only needed +# transiently below, to bake this build's commit into the image +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg git \ && rm -rf /var/lib/apt/lists/* # copy the dependencies file to the working directory @@ -19,6 +20,15 @@ RUN pip install -r requirements.txt # copy the content of the local src directory to the working directory COPY . . +# Record which commit and when this image was built, so the running app +# can show it (Settings page, /health) - a full-image-rebuild webhook +# deploy gives no other visible confirmation that a push actually landed +# in the running container. .git is removed right after so the image +# doesn't carry the repo's full history. +RUN (git rev-parse --short HEAD 2>/dev/null || echo unknown) > /app/BUILD_VERSION \ + && date -u +"%Y-%m-%d %H:%M UTC" >> /app/BUILD_VERSION \ + && rm -rf /app/.git + EXPOSE 5000 # add healthcheck using Python standard library diff --git a/README.md b/README.md index e5a2f6e..90777c8 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ triggers a full image **rebuild** from the Dockerfile (confirmed, not just a container restart), so Dockerfile changes (e.g. adding `ffmpeg`) take effect on the very next push without any manual step. +**Confirming a deploy landed:** the Dockerfile bakes the exact commit and +build time into the image as `/app/BUILD_VERSION` (`git rev-parse` at build +time, `.git` itself is deleted again right after so it doesn't ship in the +image) - shown at the bottom of the Settings page and in `/health`'s JSON +response, so after a push you can check the running container actually +picked it up instead of guessing. Reads as `dev` outside Docker (no +`BUILD_VERSION` file to read). + --- ## Local development @@ -109,12 +117,15 @@ silently stay blank instead of erroring. the same picker Manage Library's Move action uses. The picker excludes actual course/item folders, not just organizational ones: a leaf folder holding a single non-video/audio file (an ebook, an audiobook in a - format this app doesn't play, etc.), and a folder whose whole subtree - has no video/audio anywhere in it at all (a course's bundled source - code, a Python virtualenv, project assets, ...) even though it has - plenty of subfolders - both cases Library browsing's own course - detection wouldn't catch either, since there's no video/audio file to - key off. Nothing on disk moves until you review and hit Apply. Matching + format this app doesn't play, etc.); a folder whose whole subtree has no + video/audio anywhere in it at all (a course's bundled source code, a + Python virtualenv, project assets, ...) even though it has plenty of + subfolders; and a release-bundle folder that wraps a single real course + one level down under its own "course display name" folder, alongside + unrelated (possibly empty) junk siblings at the same level - all three + cases Library browsing's own course detection wouldn't catch either, + since it only checks one level of nesting for video/audio. Nothing on + disk moves until you review and hit Apply. Matching ignores common noise (e-learning platform names, release/distribution-group tags, dates) via a stopword list, and beyond that treats a match against a folder's own deliberate diff --git a/offlineu_core.py b/offlineu_core.py index d3d1414..c1f3c16 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -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(): diff --git a/templates/settings.html b/templates/settings.html index a251428..ce6f091 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -466,6 +466,10 @@ {{ icons.icon('check', 14) }} Saved + +

+ Version {{ build_version }} +