diff --git a/README.md b/README.md index 5e43942..e5a2f6e 100644 --- a/README.md +++ b/README.md @@ -107,12 +107,15 @@ silently stay blank instead of erroring. dropdown and typing just the new folder's own name, with a live "Will create: X/Y" preview so the resulting path is confirmed before applying - the same picker Manage Library's Move action uses. The picker excludes - actual course/item folders, not just organizational ones - including a - leaf folder holding a single non-video/audio file (an ebook, an - audiobook in a format this app doesn't play, etc.) that 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 ignores common noise (e-learning + 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 + 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 name as always stronger evidence than a word only borrowed from a diff --git a/offlineu_core.py b/offlineu_core.py index b0e226c..d3d1414 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -1286,6 +1286,44 @@ def _is_unrecognized_leaf_item(directory: Path) -> bool: return any(e.is_file() and not e.name.startswith('.') for e in entries) +def _subtree_has_any_media(directory: Path, max_depth: int = 8) -> bool: + """Whether `directory` or anything under it (bounded depth, so a huge + non-course tree can't make a scan slow) contains at least one + recognized video/audio file anywhere. False for a folder that's really + a course's bundled extras - downloaded source code, a Python + virtualenv/dependency dump, a project's asset tree, etc. - which can + have plenty of subfolders (so _is_unrecognized_leaf_item doesn't catch + it) but no lecture content anywhere in them.""" + try: + entries = list(directory.iterdir()) + except (PermissionError, OSError): + return False + for e in entries: + if e.is_file() and not e.name.startswith('.') and e.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS: + return True + if max_depth <= 0: + return False + for e in entries: + if e.is_dir() and not e.name.startswith('.') and _subtree_has_any_media(e, max_depth - 1): + return True + return False + + +def _is_media_free_subtree(directory: Path) -> bool: + """A folder that has subfolders (so it isn't just an intentionally + empty, freshly-created category) but no video/audio anywhere beneath + it - excluded from the destination picker for the same reason as + _is_unrecognized_leaf_item, just for a deeper non-course tree instead + of a single stray file.""" + try: + has_subdir = any(e.is_dir() and not e.name.startswith('.') for e in directory.iterdir()) + except (PermissionError, OSError): + return False + if not has_subdir: + return False + return not _subtree_has_any_media(directory) + + def _build_category_index() -> List[Dict[str, Any]]: """ Every category/subcategory folder currently in the library (e.g. IT, @@ -1305,13 +1343,14 @@ def _build_category_index() -> List[Dict[str, Any]]: categories' course titles) can't outweigh a specific match elsewhere just by sharing more of it. Stops recursing into a folder once it reads as a course itself - (_looks_like_course, the same rule the Library browser uses) or as an + (_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, so _looks_like_course - doesn't catch it either), so individual courses and standalone files - 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. + audiobook/misc file that isn't video or audio), or 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. """ library_root = Path(get_library_root()) try: @@ -1334,7 +1373,7 @@ def _build_category_index() -> List[Dict[str, Any]]: continue except OSError: pass - if _looks_like_course(entry) or _is_unrecognized_leaf_item(entry): + if _looks_like_course(entry) or _is_unrecognized_leaf_item(entry) or _is_media_free_subtree(entry): continue new_parts = path_parts + [entry.name] path_tokens: Set[str] = set()