diff --git a/README.md b/README.md
index 3af3711..b267f42 100644
--- a/README.md
+++ b/README.md
@@ -88,19 +88,52 @@ silently stay blank instead of erroring.
- Cover art: uses a manually-placed `cover`/`folder`/`thumbnail`/`thumb`/
`poster` image if a course has one, otherwise auto-generates one via
`ffmpeg` from a frame of the course's first video
-- Hide courses/folders from the browser without touching anything on disk;
- bulk-select to hide or queue several at once
-- Bulk rename across course/folder names (find & replace)
-- **Sort Unsorted** ([/unsorted](templates/unsorted.html), linked from
- Settings): drop new/incoming courses into an `Unsorted` folder at the
- library root, then scan proposes a destination for each one by keyword
+- **File Management** ([/unsorted](templates/unsorted.html), linked from
+ Settings and from a footer link on the dashboard): a dedicated page for
+ everything that touches files on disk, since it needs more room than a
+ Settings card and warrants review before anything actually changes.
+ - *Sort Unsorted*: drop new/incoming courses into an `Unsorted` folder at
+ the library root, then scan proposes a destination for each one by keyword
overlap against the *existing* category tree - read fresh from disk every
scan, so it adapts to whatever folders you actually have rather than any
hardcoded subject list. Three outcomes per item: a confident match to an
existing folder, a suggested new subfolder under a broader category match,
or "needs review" (unchecked by default) when nothing overlaps at all.
- Every destination is editable before applying, and nothing on disk moves
- until you review and hit Apply.
+ Every row has a folder-picker dropdown (listing every real category in the
+ library, plus "+ Create new folder…" to type a brand-new path) so a wrong
+ or low-confidence guess is one click to correct, and a name field to
+ rename the course's folder in the same move. 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
+ sibling course's title - and among those borrowed words, downweights
+ ones that recur across many categories (a prolific creator's name, etc.)
+ so they can't outvote a genuinely specific word just by sharing more of
+ them.
+ - *Refresh Library*: manually bypasses the 5-minute filesystem-scan cache,
+ for when files were added/removed directly on disk.
+ - *Bulk Rename*: find & replace across every course/folder name in the
+ library at once, with a per-match preview and the ability to drop
+ individual matches before applying.
+ - *Manage Library*: hide courses/folders from the browser without
+ touching anything on disk (hiding a folder hides everything inside it;
+ bulk-select elsewhere to hide or queue several at once), rename a
+ course/folder in place, or **move** one anywhere else in the library via
+ the same folder-picker/create-new-folder UI Sort Unsorted uses - handy
+ for correcting a bad auto-sort later or just reorganizing.
+ - *Duplicate Courses*: scans every course in the library (Unsorted
+ included) for names that look like the same thing filed twice, using the
+ same tokenizer as Sort Unsorted so a platform-name or release-tag
+ difference doesn't hide a real duplicate. Groups by similarity (union of
+ any two courses over the threshold, transitively) rather than showing
+ raw pairs; each course in a group gets a one-click Hide.
+ - *Clean Up Stale References*: finds entries in the hidden-paths list,
+ Next Up queue, or Recently Viewed history that point at a path no longer
+ on disk - normally from renaming/moving/deleting a course directly on
+ the NAS instead of through the app (doing it through the app already
+ keeps these in sync). Review individually or clear them all at once;
+ course files themselves are never touched.
**Dashboard**
- Library-wide stats (courses, lessons completed, time watched, time
@@ -138,8 +171,8 @@ silently stay blank instead of erroring.
actual color distance), or a fully custom accent color, corner radius,
and card style
- Font, text size, page width, and spacing density
-- Default library path override + manual "Refresh Library" (bypasses the
- 5-minute filesystem-scan cache)
+- Default library path override (Refresh Library and Bulk Rename live on the
+ File Management page - see above)
**Backup & integrations**
- One-click backup export and restore: settings, hidden-path choices, the
diff --git a/offlineu_core.py b/offlineu_core.py
index 822c49d..e2aa2d7 100644
--- a/offlineu_core.py
+++ b/offlineu_core.py
@@ -1009,10 +1009,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None:
"""
After a directory in the library is renamed/moved on disk, rewrite any
stored absolute paths that pointed inside it (hidden_paths.json,
- recent_views.json's course_path) so curation and Recently Viewed don't
- silently go stale. Lesson-level progress needs no rebasing - it lives
- inside the directory itself, keyed by paths relative to it, so it moves
- with the rename automatically.
+ next_up.json, recent_views.json's course_path) so curation, the Next
+ Up queue, and Recently Viewed don't silently go stale. Lesson-level
+ progress needs no rebasing - it lives inside the directory itself,
+ keyed by paths relative to it, so it moves with the rename
+ automatically.
"""
hidden = get_hidden_paths()
rebased_hidden = [_rebase_prefix(p, old_abs, new_abs) for p in hidden]
@@ -1021,6 +1022,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None:
with open(HIDDEN_PATHS_FILE, 'w') as f:
json.dump(sorted(set(rebased_hidden)), f, indent=2)
+ queued = get_next_up_paths()
+ rebased_queue = [_rebase_prefix(p, old_abs, new_abs) for p in queued]
+ if rebased_queue != queued:
+ _save_next_up_paths(rebased_queue)
+
views = get_recent_views()
changed = False
for entry in views:
@@ -1200,6 +1206,29 @@ _TOKEN_STOPWORDS = {
'a', 'an', 'and', 'for', 'from', 'in', 'is', 'of', 'on', 'or', 'the', 'to', 'with',
'course', 'courses', 'training', 'class', 'complete', 'full', 'guide', 'tutorial',
'part', 'vol', 'volume', 'edition',
+ # Skill-level/marketing filler - describes the packaging, not the
+ # subject, same spirit as 'complete'/'guide'/'tutorial' above.
+ 'advanced', 'intermediate', 'beginner', 'beginners', 'basics', 'basic',
+ 'essentials', 'fundamentals', 'mastering', 'master', 'crash', 'level', 'levels',
+ # E-learning platforms/marketplaces - whichever site a course was bought
+ # or ripped from says nothing about its subject, and it shows up in
+ # almost every course name from that source.
+ 'udemy', 'pluralsight', 'lynda', 'linkedin', 'linkedinlearning', 'coursera',
+ 'skillshare', 'packt', 'packtpub', 'oreilly', 'edx', 'educative', 'egghead',
+ 'frontendmasters', 'codecademy', 'udacity', 'skillsoft', 'cbtnuggets', 'cbt',
+ 'itprotv', 'cybrary', 'tutsplus', 'masterclass', 'treehouse', 'datacamp',
+ # Scene/distribution-group tags and rip metadata that piggyback on
+ # downloaded course names - never a subject, always noise.
+ 'bookware', 'tutsnode', 'ftu', 'ftuforum', 'freecoursesonline', 'freetutorials',
+ 'coursedrive', 'downtem', 'glodls', 'garr', 'ridware', 'illiterate', 'zh',
+ 'nfo', 'readnfo', 'repack', 'reup', 'reupload', 'repost', 'release', 'released',
+ 'rip', 'x264', 'x265', 'www', 'com', 'net', 'org', 'info',
+ # "Freshness" marketing words - describe the release, not the subject.
+ 'update', 'updated', 'new', 'newest', 'latest', 'final', 'retail',
+ # Calendar words - dates say nothing about what a course is about.
+ 'january', 'february', 'march', 'april', 'june', 'july', 'august', 'september',
+ 'october', 'november', 'december', 'jan', 'feb', 'mar', 'apr', 'jun', 'jul',
+ 'aug', 'sep', 'sept', 'oct', 'nov', 'dec',
}
@@ -1236,16 +1265,26 @@ def _tokenize_ordered(name: str) -> List[str]:
def _build_category_index() -> List[Dict[str, Any]]:
"""
Every category/subcategory folder currently in the library (e.g. IT,
- IT/AWS) as a possible destination, each with keyword tokens from its
- own path components plus the titles of any courses already filed
- directly under it - that second part lets a folder match on subjects
- its name alone doesn't mention (an "IT/AWS" folder full of courses
- titled "...Solutions Architect..." now also matches on "solutions" and
- "architect"), without hardcoding any subject list. Stops recursing
- into a folder once it reads as a course itself (_looks_like_course,
- the same rule the Library browser uses), so individual courses 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.
+ IT/AWS) as a possible destination, each with keyword tokens split into
+ two tiers of trust:
+ - 'path_tokens': from the folder's own path components - a deliberate
+ name the user chose, so a match here is always taken at face value.
+ - 'bonus_tokens': from the titles of courses already filed directly
+ under it - lets a folder match on subjects its name alone doesn't
+ mention (an "IT/AWS" folder full of courses titled "...Solutions
+ Architect..." now also matches on "solutions" and "architect"),
+ without hardcoding any subject list. Since these come from
+ whatever the courses happen to be named, they're incidental rather
+ than deliberate - _propose_destination downweights them by how
+ many *different* categories share the same bonus word, so a word
+ that turns out to be common clutter (recurring across many
+ 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), so
+ individual courses 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:
@@ -1271,20 +1310,23 @@ def _build_category_index() -> List[Dict[str, Any]]:
if _looks_like_course(entry):
continue
new_parts = path_parts + [entry.name]
- tokens: Set[str] = set()
+ path_tokens: Set[str] = set()
for part in new_parts:
- tokens |= _tokenize(part)
+ path_tokens |= _tokenize(part)
+ bonus_tokens: Set[str] = set()
try:
for child in entry.iterdir():
if child.is_dir() and not child.name.startswith('.') and _looks_like_course(child):
- tokens |= _tokenize(child.name)
+ bonus_tokens |= _tokenize(child.name)
except (PermissionError, OSError):
pass
+ bonus_tokens -= path_tokens
categories.append({
'path': str(entry),
'relative': '/'.join(new_parts),
'depth': len(new_parts),
- 'tokens': tokens,
+ 'path_tokens': path_tokens,
+ 'bonus_tokens': bonus_tokens,
})
walk(entry, new_parts)
@@ -1292,6 +1334,20 @@ def _build_category_index() -> List[Dict[str, Any]]:
return categories
+def _bonus_token_frequency(categories: List[Dict[str, Any]]) -> Dict[str, int]:
+ """How many distinct categories each bonus token shows up in, across
+ the whole library - used to tell a word that's actually specific to one
+ place ("cloudformation") from one that's just common clutter recurring
+ in course titles everywhere ("advanced", a creator's name, etc.), so
+ the latter can be downweighted instead of needing to be individually
+ named as a stopword ahead of time."""
+ freq: Dict[str, int] = {}
+ for cat in categories:
+ for token in cat['bonus_tokens']:
+ freq[token] = freq.get(token, 0) + 1
+ return freq
+
+
def _list_unsorted_items() -> List[Path]:
"""Direct children of the Unsorted folder - each one is a course
waiting to be filed into the real category tree."""
@@ -1304,7 +1360,8 @@ def _list_unsorted_items() -> List[Path]:
)
-def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Dict[str, Any]:
+def _propose_destination(item_name: str, categories: List[Dict[str, Any]],
+ bonus_df: Dict[str, int]) -> Dict[str, Any]:
"""
Best-effort destination for one Unsorted item, by keyword overlap
against the existing category tree. Three outcomes:
@@ -1314,11 +1371,26 @@ def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Di
named from whichever of the item's own keywords the category
didn't already cover.
- 'manual': nothing matches with any confidence - needs a human.
- "Confident" here means either a fairly specific existing folder (2+
- levels deep, e.g. IT/AWS) matched on even one shared keyword, or a
- shallower folder matched on 2+ - a single shared word with a bare
- top-level category ("IT") isn't enough to drop something straight
- into it; that's exactly the case a new subfolder proposal is for.
+ Categories are ranked by evidence quality, not raw shared-word count -
+ a match on a *path* token (the folder's own deliberate name, e.g. the
+ "AWS" in IT/AWS) always outranks any number of *bonus* tokens (words
+ only borrowed from a sibling course's title - see
+ _build_category_index), since a folder's own name is deliberate and a
+ sibling course's title is incidental. Among bonus-only matches, the
+ single least-diluted word wins rather than the total - a bonus word
+ shared by only one category counts fully, one recurring across N
+ categories counts for 1/N - so several incidental, recurring words (a
+ prolific creator's name, a repeated marketing phrase, etc. showing up
+ across many categories' course titles) can't outvote one genuinely
+ specific word just by there being more of them, and don't need to be
+ individually named as stopwords ahead of time to be discounted.
+ "Confident" then means the best match is either a path-token hit or an
+ essentially-unique bonus word (not diluted across other categories),
+ AND it's either at a fairly specific existing folder (2+ levels deep,
+ e.g. IT/AWS) or corroborated by a second shared word - one weak,
+ common word alone isn't enough to drop something straight into a bare
+ top-level category ("IT"); that's exactly the case a new subfolder
+ proposal is for.
"""
item_tokens_ordered = _tokenize_ordered(item_name)
item_tokens = set(item_tokens_ordered)
@@ -1328,26 +1400,36 @@ def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Di
scored = []
for cat in categories:
- overlap = item_tokens & cat['tokens']
- if overlap:
- scored.append((len(overlap), cat['depth'], cat, overlap))
+ overlap = item_tokens & (cat['path_tokens'] | cat['bonus_tokens'])
+ if not overlap:
+ continue
+ path_hits = overlap & cat['path_tokens']
+ bonus_overlap = overlap - cat['path_tokens']
+ bonus_best = max((1.0 / bonus_df.get(t, 1) for t in bonus_overlap), default=0.0)
+ scored.append({
+ 'cat': cat, 'overlap': overlap,
+ 'path_hits': len(path_hits), 'bonus_best': bonus_best, 'count': len(overlap),
+ })
if not scored:
return {'type': 'manual', 'destination': None,
'reason': "No existing folder shares any keywords with this name."}
- scored.sort(key=lambda s: (s[0], s[1]), reverse=True)
- best_count, best_depth, best_cat, best_overlap = scored[0]
- overlap_words = ', '.join(w for w in item_tokens_ordered if w in best_overlap)
+ scored.sort(key=lambda s: (s['path_hits'], s['bonus_best'], s['count'], s['cat']['depth']), reverse=True)
+ best = scored[0]
+ best_cat = best['cat']
+ overlap_words = ', '.join(w for w in item_tokens_ordered if w in best['overlap'])
+ best_all_tokens = best_cat['path_tokens'] | best_cat['bonus_tokens']
- if best_depth >= 2 or best_count >= 2:
+ is_strong_match = best['path_hits'] >= 1 or best['bonus_best'] >= 1.0
+ if is_strong_match and (best_cat['depth'] >= 2 or best['count'] >= 2):
return {
'type': 'existing',
'destination': best_cat['relative'],
'reason': f'Matches existing folder "{best_cat["relative"]}" on: {overlap_words}',
}
- leftover = item_tokens - best_cat['tokens']
+ leftover = item_tokens - best_all_tokens
if leftover:
# preserve the item's own word order rather than the set's
# arbitrary one, so "Docker Compose Basics" suggests "Compose
@@ -1368,6 +1450,110 @@ def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Di
}
+def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]:
+ """
+ Group courses across the whole library (Unsorted included, so a
+ just-dropped item that duplicates something already filed gets caught
+ too) whose names look like the same thing filed twice - a re-download
+ that landed in a different category, or a course that's both sorted
+ and still sitting in Unsorted. Reuses Sort Unsorted's own
+ tokenizer/stopword list, so platform names, release-group tags, and
+ dates are already ignored - "AWS Certified Solutions Architect" and
+ "AWS.Certified.Solutions.Architect.2024.BOOKWARE-XYZ" tokenize down to
+ the same core words despite looking nothing alike as strings.
+ Pairwise Jaccard similarity (shared tokens / all tokens) at or above
+ min_similarity gets union-found into groups, so if A matches B and B
+ matches C, all three land in one group instead of two overlapping
+ pairs. Nothing here touches disk - it's read-only, for the user to
+ review and hide/rename/move manually.
+ """
+ entries = []
+ for course in get_all_course_dirs():
+ tokens = _tokenize(course.name)
+ if tokens:
+ entries.append({'path': str(course), 'name': course.name, 'tokens': tokens})
+
+ n = len(entries)
+ parent = list(range(n))
+
+ def find(i: int) -> int:
+ while parent[i] != i:
+ parent[i] = parent[parent[i]]
+ i = parent[i]
+ return i
+
+ def union(i: int, j: int) -> None:
+ ri, rj = find(i), find(j)
+ if ri != rj:
+ parent[ri] = rj
+
+ pair_similarity: Dict[Tuple[int, int], float] = {}
+ for i in range(n):
+ for j in range(i + 1, n):
+ a, b = entries[i]['tokens'], entries[j]['tokens']
+ union_tokens = a | b
+ if not union_tokens:
+ continue
+ similarity = len(a & b) / len(union_tokens)
+ if similarity >= min_similarity:
+ pair_similarity[(i, j)] = similarity
+ union(i, j)
+
+ clusters: Dict[int, List[int]] = {}
+ for i in range(n):
+ clusters.setdefault(find(i), []).append(i)
+
+ groups = []
+ for members in clusters.values():
+ if len(members) < 2:
+ continue
+ best_similarity = max(
+ (sim for (i, j), sim in pair_similarity.items() if i in members and j in members),
+ default=0.0
+ )
+ groups.append({
+ 'courses': sorted(
+ ({'path': entries[i]['path'], 'name': entries[i]['name']} for i in members),
+ key=lambda c: c['name'].lower()
+ ),
+ 'similarity': round(best_similarity, 2),
+ })
+
+ groups.sort(key=lambda g: g['similarity'], reverse=True)
+ return groups
+
+
+def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
+ """
+ Entries in hidden_paths.json / next_up.json / recent_views.json that
+ point at a path no longer on disk - typically because a course was
+ renamed, moved, or deleted directly on the NAS rather than through the
+ app (a rename/move made through the app already rebases these, see
+ rebase_library_path). Read-only; nothing is removed until
+ /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': []}
+
+ for path in get_hidden_paths():
+ if not os.path.isdir(path):
+ stale['hidden'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
+
+ for path in get_next_up_paths():
+ if not os.path.isdir(path):
+ stale['next_up'].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', '')
+ if path and path not in seen and not os.path.isdir(path):
+ seen.add(path)
+ name = entry.get('course_name') or os.path.basename(path.rstrip(os.sep)) or path
+ stale['recent_views'].append({'path': path, 'name': name})
+
+ return stale
+
+
def _probe_media_duration_seconds(file_path: Path) -> Optional[float]:
"""Read a single media file's duration via ffprobe. None if ffprobe is
missing, the file isn't readable, or the output can't be parsed."""
@@ -2045,6 +2231,20 @@ def get_recent_views() -> List[Dict[str, Any]]:
return []
+def remove_recent_views_for_path(path: str) -> List[Dict[str, Any]]:
+ """Drop every Recently Viewed entry for one course path - there can be
+ several, one per lesson. Used to clear a stale reference left behind
+ when a course was deleted/moved outside the app."""
+ entries = get_recent_views()
+ normalized = os.path.abspath(path)
+ remaining = [e for e in entries if e.get('course_path') != normalized]
+ if remaining != entries:
+ os.makedirs(DATA_DIR, exist_ok=True)
+ with open(RECENT_VIEWS_FILE, 'w') as f:
+ json.dump(remaining, f, indent=2)
+ return remaining
+
+
def _read_lesson_progress_entry(course_path: str, lesson_path: str) -> Dict[str, Any]:
"""
Read a single lesson's progress entry directly from its course's own
@@ -2681,16 +2881,20 @@ def scan_unsorted_api():
items = _list_unsorted_items()
categories = _build_category_index()
+ bonus_df = _bonus_token_frequency(categories)
results = []
for item in items:
- proposal = _propose_destination(item.name, categories)
+ proposal = _propose_destination(item.name, categories, bonus_df)
results.append({'name': item.name, 'path': str(item), **proposal})
+ category_paths = sorted((c['relative'] for c in categories), key=str.lower)
+
return jsonify({
'items': results,
'unsorted_exists': True,
'unsorted_path': unsorted_root,
'category_count': len(categories),
+ 'categories': category_paths,
})
@@ -2698,8 +2902,10 @@ def scan_unsorted_api():
def apply_unsorted_api():
"""
Move a batch of Unsorted items to their (possibly user-edited)
- destinations, creating new subfolders as needed. Every source must
- actually be inside the Unsorted folder and every destination must
+ destinations, creating new subfolders as needed, and optionally renaming
+ each item's folder in the same step (move + rename is one filesystem
+ operation either way, so there's no reason to make it two). Every source
+ must actually be inside the Unsorted folder and every destination must
stay inside the library root and outside Unsorted itself - guards
against a hand-edited destination path escaping the library, the same
zip-slip-style check used for backup restore.
@@ -2738,9 +2944,16 @@ def apply_unsorted_api():
errors.append({'source': source, 'error': 'Destination cannot be inside Unsorted'})
continue
- final_path = os.path.join(dest_dir, os.path.basename(source_abs))
+ final_name = ((move or {}).get('name') or '').strip()
+ if not final_name:
+ final_name = os.path.basename(source_abs)
+ if os.sep in final_name or (os.altsep and os.altsep in final_name) or final_name in ('.', '..'):
+ errors.append({'source': source, 'error': f'"{final_name}" is not a valid folder name'})
+ continue
+
+ final_path = os.path.join(dest_dir, final_name)
if os.path.exists(final_path):
- errors.append({'source': source, 'error': f'"{os.path.basename(source_abs)}" already exists at that destination'})
+ errors.append({'source': source, 'error': f'"{final_name}" already exists at that destination'})
continue
try:
@@ -2754,6 +2967,129 @@ def apply_unsorted_api():
return jsonify({'success': True, 'moved': len(moved), 'errors': errors})
+@app.route('/api/library/categories')
+def library_categories_api():
+ """Every category/subcategory folder in the library, for destination
+ pickers - Sort Unsorted's own picker gets this from /api/unsorted/scan
+ already; this is the same list for callers (like Manage Library's move
+ action) that need it without depending on an Unsorted-folder scan."""
+ categories = _build_category_index()
+ return jsonify({'categories': sorted((c['relative'] for c in categories), key=str.lower)})
+
+
+@app.route('/api/library/move', methods=['POST'])
+def move_library_item_api():
+ """
+ Move (and optionally rename) any course or folder already in the
+ library to a different location - the general-purpose version of
+ /api/unsorted/apply, for reorganizing something already filed rather
+ than just-arrived. Same source/destination-inside-library-root safety
+ checks, plus one apply_unsorted_api doesn't need: the destination
+ can't be inside the source's own subtree, since here the source can
+ itself be a category folder with children, not just a leaf course.
+ """
+ data = request.json or {}
+ source = (data.get('source') or '').strip()
+ destination_relative = (data.get('destination') or '').strip().strip('/')
+ final_name = (data.get('name') or '').strip()
+
+ if not source or not destination_relative:
+ return jsonify({'success': False, 'error': 'Missing source or destination'}), 400
+
+ library_root = os.path.abspath(get_library_root())
+ source_abs = os.path.abspath(source)
+
+ if not (source_abs == library_root or source_abs.startswith(library_root + os.sep)):
+ return jsonify({'success': False, 'error': 'Source is outside the library'}), 403
+ if source_abs == library_root:
+ return jsonify({'success': False, 'error': 'Cannot move the library root itself'}), 400
+ if not os.path.isdir(source_abs):
+ return jsonify({'success': False, 'error': 'Source no longer exists'}), 404
+
+ dest_dir = os.path.abspath(os.path.join(library_root, destination_relative))
+ if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
+ return jsonify({'success': False, 'error': 'Destination is outside the library'}), 403
+ if dest_dir == source_abs or dest_dir.startswith(source_abs + os.sep):
+ return jsonify({'success': False, 'error': 'Cannot move a folder into itself'}), 400
+
+ if not final_name:
+ final_name = os.path.basename(source_abs)
+ if os.sep in final_name or (os.altsep and os.altsep in final_name) or final_name in ('.', '..'):
+ return jsonify({'success': False, 'error': f'"{final_name}" is not a valid folder name'}), 400
+
+ final_path = os.path.join(dest_dir, final_name)
+ if final_path == source_abs:
+ return jsonify({'success': False, 'error': 'Already there'}), 400
+ if os.path.exists(final_path):
+ return jsonify({'success': False, 'error': f'"{final_name}" already exists at that destination'}), 409
+
+ try:
+ os.makedirs(dest_dir, exist_ok=True)
+ shutil.move(source_abs, final_path)
+ except OSError as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+ invalidate_cache()
+ rebase_library_path(source_abs, final_path)
+
+ global current_course
+ active_course_reset = False
+ if current_course is not None:
+ course_abs = os.path.abspath(current_course.path)
+ if course_abs == source_abs or course_abs.startswith(source_abs + os.sep):
+ current_course = None
+ active_course_reset = True
+
+ return jsonify({'success': True, 'new_path': final_path, 'active_course_reset': active_course_reset})
+
+
+@app.route('/api/library/duplicates')
+def duplicate_courses_api():
+ """Scan for courses that look like copies of each other by name (see
+ _find_duplicate_courses). Not run automatically like Sort Unsorted's
+ scan - it's O(n^2) over every course in the library, so it's a manual
+ trigger instead."""
+ groups = _find_duplicate_courses()
+ return jsonify({'groups': groups, 'course_count': len(get_all_course_dirs())})
+
+
+@app.route('/api/library/stale-references')
+def stale_references_api():
+ """Entries in hidden_paths/next_up/recent_views pointing at paths no
+ longer on disk (see _find_stale_references). Read-only - nothing is
+ removed until /api/library/stale-references/clean is called."""
+ return jsonify(_find_stale_references())
+
+
+@app.route('/api/library/stale-references/clean', methods=['POST'])
+def clean_stale_references_api():
+ """Remove specific stale entries the client reviewed via the scan
+ above. Re-checks each path still doesn't exist before removing it, in
+ case it became valid again between scan and apply."""
+ data = request.json or {}
+ items = data.get('items') or []
+ if not isinstance(items, list) or not items:
+ return jsonify({'success': False, 'error': 'No items provided'}), 400
+
+ removed = 0
+ for item in items:
+ category = (item or {}).get('category')
+ path = (item or {}).get('path')
+ if not path or os.path.isdir(path):
+ continue
+ if category == 'hidden':
+ set_path_hidden(path, False)
+ removed += 1
+ elif category == 'next_up':
+ set_path_queued(path, False)
+ removed += 1
+ elif category == 'recent_views':
+ remove_recent_views_for_path(path)
+ removed += 1
+
+ return jsonify({'success': True, 'removed': removed})
+
+
# Single-process personal app, so a plain dict + background thread is
# enough state for the duration prewarm - no job queue needed. Guarded by
# 'running' so a second click while one's in flight just reports progress
diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html
index 461cf6a..c3b91c6 100644
--- a/templates/course_dashboard.html
+++ b/templates/course_dashboard.html
@@ -197,6 +197,21 @@
transform: translateY(0);
}
+ .page-footer-link {
+ text-align: center;
+ padding: 24px 0 8px;
+ }
+ .page-footer-link a {
+ color: var(--text-muted);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.85em;
+ }
+ .page-footer-link a:hover {
+ color: var(--accent);
+ }
.bottom-tab-bar {
position: fixed;
left: 0;
@@ -1313,6 +1328,10 @@
{% endif %}
{% endif %}
+
+