Exclude media-free subtrees from the destination picker too

The previous leaf-item fix only caught a single stray file with no
subfolders - it missed a course whose bundled extras (downloaded
source code, a Python virtualenv/dependency dump, project assets)
have plenty of subfolders but no video/audio anywhere in them, which
still left the course itself, and every one of those subfolders,
selectable as destinations. Add a subtree-wide media check (bounded
depth) so a folder with subfolders but no media anywhere beneath it is
excluded the same way, while a folder that still has a real course's
media somewhere inside it - or is simply empty, e.g. a freshly-created
category - stays pickable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 13:21:39 -04:00
co-authored by Claude Sonnet 5
parent 62bb5e1318
commit 7fbe63e05c
2 changed files with 55 additions and 13 deletions
+9 -6
View File
@@ -107,12 +107,15 @@ silently stay blank instead of erroring.
dropdown and typing just the new folder's own name, with a live "Will 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 - create: X/Y" preview so the resulting path is confirmed before applying -
the same picker Manage Library's Move action uses. The picker excludes the same picker Manage Library's Move action uses. The picker excludes
actual course/item folders, not just organizational ones - including a actual course/item folders, not just organizational ones: a leaf folder
leaf folder holding a single non-video/audio file (an ebook, an holding a single non-video/audio file (an ebook, an audiobook in a
audiobook in a format this app doesn't play, etc.) that Library format this app doesn't play, etc.), and a folder whose whole subtree
browsing's own course detection wouldn't catch either, since there's no has no video/audio anywhere in it at all (a course's bundled source
video/audio file to key off. Nothing on disk moves until you review and code, a Python virtualenv, project assets, ...) even though it has
hit Apply. Matching ignores common noise (e-learning 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 platform names, release/distribution-group tags, dates) via a stopword
list, and beyond that treats a match against a folder's own deliberate 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 name as always stronger evidence than a word only borrowed from a
+46 -7
View File
@@ -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) 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]]: def _build_category_index() -> List[Dict[str, Any]]:
""" """
Every category/subcategory folder currently in the library (e.g. IT, 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 categories' course titles) can't outweigh a specific match
elsewhere just by sharing more of it. elsewhere just by sharing more of it.
Stops recursing into a folder once it reads as a course itself 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/ unrecognized leaf item (_is_unrecognized_leaf_item - an ebook/
audiobook/misc file that isn't video or audio, so _looks_like_course audiobook/misc file that isn't video or audio), or a subtree with no
doesn't catch it either), so individual courses and standalone files video/audio anywhere in it at all (_is_media_free_subtree - a course's
never show up as if they were categories to file things under. The bundled source code/project files, a Python virtualenv, etc.), so
Unsorted folder itself is excluded - it's the source, never a valid individual courses and their non-lecture contents never show up as if
destination. 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()) library_root = Path(get_library_root())
try: try:
@@ -1334,7 +1373,7 @@ def _build_category_index() -> List[Dict[str, Any]]:
continue continue
except OSError: except OSError:
pass 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 continue
new_parts = path_parts + [entry.name] new_parts = path_parts + [entry.name]
path_tokens: Set[str] = set() path_tokens: Set[str] = set()