The destination picker (Sort Unsorted, Manage Library Move) was listing individual course/item folders as pickable destinations whenever they held content course detection doesn't recognize as video/audio - ebooks, audiobooks in unsupported formats, etc. Add a leaf-item check scoped to the picker's own category index so these no longer show up, without touching the shared course-detection heuristic used elsewhere (Library browsing, search, stats). Also replace "Create new folder"'s single free-text path field with a parent-folder picker plus a plain new-folder-name field and a live "Will create: X/Y" preview, so the resulting path is confirmed before applying instead of hand-typed blind. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OfflineU
Self-hosted course viewer for a local video/audio/text training library. Runs as a single Flask app in Docker, tracks watch progress and notes per lesson, and gives the whole library a dashboard-style home page instead of a bare file browser.
This is a personal deployment, not a public project — this doc is internal reference for running/maintaining it, not a pitch.
Screenshot predates the current theming/icon work — kept for a rough idea
of layout, not pixel-accurate.
Deployment
Deployed via docker-compose.yml, built directly from the Dockerfile in
this repo (pull_policy: build, not a registry pull):
services:
offlineu:
build: .
pull_policy: build
container_name: offlineu
network_mode: host
ports:
- "5000:5000"
environment:
- PUID=1000
- PGID=10
- TZ=America/New_York
- FLASK_ENV=production
volumes:
- /volume1/files/training:/app/courses # course library
- /volume2/docker/offlineu/data:/app/data # settings/progress/notes
restart: unless-stopped
Auto-deploy: the Gitea repo has a webhook to Dockhand — a push to main
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.
Local development
pip install -r requirements.txt
python offlineu_core.py --library-path /path/to/courses --debug
Opens on http://127.0.0.1:5000. ffprobe (from ffmpeg) needs to be on
PATH for video-duration lookups to work locally; without it those just
silently stay blank instead of erroring.
CLI options
| Option | Description |
|---|---|
--host |
Bind host (default 0.0.0.0) |
--port |
Bind port (default 5000) |
--debug |
Enable Flask debug mode |
--create-templates |
Regenerate default templates if missing |
--library-path |
Course library root (overrides COURSES_LIBRARY_PATH) |
course_path (positional) |
Load a specific course directly at startup |
Environment variables
| Variable | Default | Purpose |
|---|---|---|
COURSES_LIBRARY_PATH |
/app/courses |
Course library root |
OFFLINEU_DATA_DIR |
/app/data |
Where settings/progress/notes data lives |
AUTO_LOAD_COURSE |
— | Course path to load automatically on start |
Features
Library browsing
- Lazy-loading folder browser (grid or list view) with search, including an opt-in transcript search across subtitle files
- Course cards show media file count, total video/audio runtime, and completion % at a glance
- Cover art: uses a manually-placed
cover/folder/thumbnail/thumb/posterimage if a course has one, otherwise auto-generates one viaffmpegfrom a frame of the course's first video - File Management (/unsorted, its own item
("Files") in the bottom tab bar alongside Home/Notes/Help/Settings): 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
Unsortedfolder 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 row has a folder-picker dropdown (listing every real category in the library, plus "+ Create new folder…") 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. Creating a new folder means picking its parent from a 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 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. Three match modes: plain text
(default, literal substring), wildcard (shell-style
*/?, e.g..BOOKWARE*catches.BOOKWARE-GETH,.BOOKWARE-BOOKTIME,.BOOKWARE-BLZiSO, etc. in one pass), or full regex (with backreferences in the replacement, e.g.\1). - 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, or a Delete that permanently removes it from disk (the one destructive action in the whole app - confirmed with the full path before it runs). A group can also be marked "Not a duplicate" if the match is wrong, which excludes that specific pair from future scans without touching anything else that happens to match one of those courses; ignored pairs are listed (and reversible) under "Ignored matches," and persist through backup/restore alongside hidden paths and Next Up.
- 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.
- Sort Unsorted: drop new/incoming courses into an
Dashboard
- Library-wide stats (courses, lessons completed, time watched, time remaining, day streak) and a 90-day activity heatmap
- Next Up queue (manually curated, reorderable)
- Pick Back Up (courses with progress that have gone stale)
- Recently Added / Recently Viewed
- Surprise Me — dice icon in the header, random pick weighted toward incomplete courses
Playback & progress
- Video/audio player with resize, playback-speed presets, and resume-from- last-position
- Auto-tracks watch progress and completion per lesson
- Video/audio durations read via
ffprobeand cached persistently per course (.offlineu_duration_cache.json), so total runtime and per-lesson length show up before you've ever pressed play — not just after; the same cache backs the library-wide "time remaining" stat - Settings → "Precompute Lengths & Cover Art" walks the whole library in the background to populate durations and thumbnails up front, with live progress
Notes
- Timestamped notes per lesson, capturable via a keyboard shortcut without leaving the player
- Notes Hub: every note across the whole library, searchable, in one list
- Per-course study guide export (markdown, organized by section)
Personalization (Settings)
- 46 built-in themes (grouped in the dropdown: Base, Editor Themes, and 5 categories of website-inspired schemes sourced from Figma's "53 Unique Website Color Schemes" - 34 of the 53 kept, all as dark/colored backgrounds rather than the source page's mostly-white mockups, after cutting a couple of neon-bright accents and pruning near-duplicates by 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 (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 Next Up queue, recent-view history, ignored-duplicate pairs, Outline config, and every course's progress/notes as a zip (not the course files themselves). Restore overwrites current data and needs a matching course folder to already exist for each course's progress to land - it's a "put my data back" action, not a merge
- Optional Outline integration — push a lesson's notes to an Outline document/collection
Data files
Everything under OFFLINEU_DATA_DIR (app-wide, not tied to a course):
| File | Contents |
|---|---|
settings.json |
Theme, layout, library path, etc. |
hidden_paths.json |
Courses/folders curated out of the browser |
next_up.json |
The Next Up queue, in order |
recent_views.json |
Cross-course "Recently Viewed" history |
ignored_duplicates.json |
Course-path pairs confirmed not duplicates |
outline_config.json |
Outline API token/collection mapping |
Per-course, written inside the course's own folder on the library volume:
| File | Contents |
|---|---|
.offlineu_progress.json |
Per-lesson completed/progress/duration/notes |
.offlineu_duration_cache.json |
ffprobe duration cache, keyed by (path, size, mtime) |
.offlineu_thumbnail.jpg |
Auto-generated cover art (only if no manual cover exists) |
Folder structure example
MyCourse/
├── Section 1/
│ ├── 01 - Intro.mp4
│ ├── 02 - Setup Guide.pdf
│ └── 03 - Quiz.html
├── Section 2/
│ ├── 04 - Advanced Tips.mp4
│ └── resources/
│ └── extras.md
├── .offlineu_progress.json ← created automatically
├── .offlineu_duration_cache.json ← created automatically
└── .offlineu_thumbnail.jpg ← created automatically, only if no manual cover exists
No metadata files needed — course/section/lesson names come straight from folder and file names.
An Unsorted/ folder at the library root (alongside the real category
folders) is the drop point for new/incoming courses — see Sort Unsorted
above.
Supported file types
| Type | Extensions |
|---|---|
| Video | .mp4 .mkv .avi .mov .webm .m4v .flv .wmv |
| Audio | .mp3 .wav .m4a .aac .ogg .flac |
| Documents | .txt .md .html .htm .pdf .docx .doc .rtf |
| Subtitles | .srt .vtt .ass .sub .sbv |
| Quizzes | Any doc file whose name contains quiz, exam, test, assessment, exercise, assignment, or homework |
Tools
tools/generate_website_scheme_themes.py is the one-off script that
produced the 34 "website scheme" theme entries in THEME_PALETTES (of
53 candidates - all 24 light-mode ones were converted to dark/colored
backgrounds per request, then 19 were cut across two review passes: 2
for a neon-bright accent, 17 for landing too close to another kept
scheme once mapped into actual UI colors, since forcing everything dark
made several collide that hadn't before) - not part of the running app,
kept for reference if those palettes ever need regenerating or
extending. build_theme() takes a force_mood='dark'|'light' override
for exactly this kind of bulk remapping. See its module docstring for
how it turned Figma's color-scheme screenshots into actual UI palettes.