Files
coursehub/README.md
T
Harwinder Sekhon 068841500b Document database schema and add comprehensive table information
- Add new 'Database Schema' section with detailed table descriptions
- Document each table's purpose, columns, and relationships
- Explain key constraints, foreign keys, and cascade-delete behavior
- List indices and their purposes for performance optimization
- Enhance Course Advisor section with advisor_requests table details
- Explain the rolling 24-hour daily limit window and token tracking
- Clarify where conversation data is stored (sessionStorage)
- Improve Settings section with database references
- Enhance Search section with specific table and field information
- Expand 'How progress works' with database table references
- Document the optimization of progress queries for performance
- Reference specific functions (courseRollups, advisorProgress)
- Clarify when tables are created lazily (login_attempts, app_meta, advisor_requests)

This makes the database structure transparent for users, operators, and developers.
2026-09-10 07:04:46 -04:00

30 KiB

CourseHub

Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design system and Plyr. No CSS framework, no build step, and no CDN — Plyr and the Inter font are vendored in assets/vendor/, so the app works with no internet access at all.

Single-page app: index.php is a static shell (sidebar, search header, mobile tab bar); all navigation, login, browsing, playback, scanning and settings happen over AJAX against the JSON API in api/ (auth.php, library.php, progress.php, scan.php, quiz.php, reset.php), with hash-based routes so views switch without page reloads and browser back/forward still work:

Route Screen
#/ home — resume card, stats, in progress, last scan
#/library, #/library/<shelf> categories, shelves or a flat catalog of every course
#/folder/N a category (its courses), or a course/section (its contents)
#/watch/N player, playlist, section quizzes
#/quiz/N take and grade a quiz
#/search/Q courses, videos and quizzes matching Q
#/settings library folder, preferences, reset

Only stream.php (the mp4 bytes) and captions.php (subtitles as WebVTT) are plain HTTP resources.

  • Recursively scans a videos directory for .mp4 files and mirrors the folder hierarchy (categories → courses → sections → videos, any nesting depth), and works out where a course starts rather than assuming a depth.
  • Per-user resume: log in from any device and continue where you left off.
  • Progress tracking at every level — each folder shows completed/total and a percentage bar computed over its whole subtree.
  • "Continue watching" strip — its play button opens the player and starts the video at the saved position — auto-play next video, playback speed, keyboard shortcuts (Plyr), completed checkmarks, natural sorting (Video2 before Video10).
  • Subtitles: a video with a sibling .srt file (Lesson.mp4 + Lesson.srt) gets a CC button in the player — the file is converted to WebVTT on the fly by captions.php, addressed by DB id like the stream.
  • Search courses, sections, videos and quizzes from the top bar — and a course's catalogue tags and blurb, once the catalogue is seeded.
  • Course details (difficulty, tags, a blurb, the learning paths a course belongs to) from the vendor's catalogue or the downloader's sidecar file — see Course catalogue below.
  • An optional AI course advisor, a bubble in the corner of every screen: "I want to learn SD-WAN, where do I start?" answered with courses from your own library, in order, with links. Off unless you switch it on — see Course advisor below.
  • Reset progress for one video, one quiz, a whole course/section, or the entire account.
  • Course and section listings switch between grid and list layout, and the library between categories, shelves and catalog; both choices are remembered.
  • Categories are the folders that sit above the courses — the library opens on them, and each one totals the courses beneath it.
  • Shelves group courses by how far you are: in progress, finished, not started. The sidebar links straight to each one.
  • Seekable streaming via HTTP Range requests; files are addressed by DB id only (no path traversal).

Layout on disk

Put whatever shape your material already has under VIDEOS_ROOT. There is no convention to follow and nothing to rename: the scanner works out which folder is a course, per branch, and everything else follows from that.

videos/
├── Cloud/                                  category
│   └── AWS Networking/                     course  (children are numbered)
│       ├── 01 - Welcome/
│       │   └── Intro.mp4
│       └── 02 - Creating a VPC/
│           └── Subnets.mp4
├── Firewalls/                              category
│   └── Palo-Alto/                          category
│       └── Palo_Alto_Firewall_Essentials/  course
│           └── 01_Welcome/…
└── System Admin/                           category
    └── Red Hat … EX457/                    course  (videos sit right in it)
        ├── 01 Introduction.mp4
        └── 02 Inventories.mp4

Where a course starts

Course roots do not sit at a fixed depth — one library can hold Category/<course>, Category/Vendor/<course> and Category/Vendor/Exam/<course> at once — so the scanner decides per branch, walking down from the top:

The folder… is a
has no videos or quizzes anywhere under it neither — it is skipped
holds videos or quizzes directly course
has children whose names read as chapters (01_, 1. , Module 2, Week 3) course
anything else category — look one level deeper

Everything under a course root is one of its sections. The result is stored as folders.is_course / folders.course_id at scan time, so the screens are a lookup rather than a guess, and re-running the scan re-derives it.

Overriding it

The rule reads chapter numbering, so it gets a course wrong when the sections carry plain names (Course/Introduction, Course/Advanced looks like two courses). Drop an empty marker file in the folder and the next scan obeys it instead of guessing:

File Effect
.course this folder is a course; everything under it is a section
.category the courses are further down — keep looking one level deeper
touch "videos/Cat/Unnumbered Course/.course"     # one course, not two
touch "videos/Cat/Exam Bundle/.category"         # each child is its own course

A marker settles only the folder it sits on. Once a course root is found, everything below it is a section, so to push a course root deeper, mark the folder above it .category. A .category on a folder that holds videos itself is ignored — those videos have to belong to something.

Two consequences worth knowing:

  • Progress means something. Shelves, the home page's "in progress" strip and every percentage are per course. Counting a whole category as one course instead buries twenty watched videos in a denominator of three thousand, and everything reads 0 % forever.
  • A folder of PDFs is not a course. Anything with no .mp4 and no quiz file anywhere beneath it never appears in the library at all.

Folder names are case-sensitive

CCNP Notes and CCNP NOtes are two directories on a Linux filesystem and the app treats them as two, because folders.path, videos.path and quizzes.path are stored utf8mb4_bin. Under the default case-insensitive collation the UNIQUE key on path merged them, and the second tree's videos were never indexed. The search box still matches without regard to case.

Setup

  1. Database — create the schema and an app user:

    sudo mysql < schema.sql
    sudo mysql -e "CREATE USER IF NOT EXISTS 'coursehub'@'localhost' IDENTIFIED BY 'coursehub';
                   GRANT ALL ON coursehub.* TO 'coursehub'@'localhost';"
    
  2. Configure — edit config.php:

    • VIDEOS_ROOT: absolute path to your course folders (must be readable by the web server / PHP user).
    • DB credentials (change the password in production).
    • ALLOW_REGISTRATION: set false once your users have accounts.
    • SESSION_LIFETIME: seconds a sign-in lasts (the example config says 30 days). Unset or 0 means a browser-session cookie that also dies after PHP's idle limit — 24 minutes by default, which signs people out over a long pause. Installs whose config.php predates the setting keep that old behaviour. If other PHP apps share the server's session directory, give this app its own session.save_path in the vhost / FPM pool.
    • DEBUG: include the exception type, file:line and stack trace in API error responses. Errors go to the PHP error log either way, so leave this false on anything publicly reachable. Installs whose config.php predates the setting behave as if it were false.

    No external binaries are needed — PHP with PDO/MySQL is the whole dependency list.

  3. Run — with the PHP built-in server for testing:

    php -S 0.0.0.0:8080 -t /path/to/coursehub
    

    or point an Apache/Nginx + PHP-FPM vhost at the directory.

  4. Index the library — click Rescan library in the top bar, or:

    php scan.php
    

    Rescan whenever you add/remove files. Re-scans are idempotent: existing videos keep their IDs (and everyone's progress); files that disappeared are hidden, not deleted, so progress survives if they come back. Each scan also re-derives which folders are courses, and brings an older install's tables up to date first, so there is no separate migration step.

  5. Seed the course catalogue (optional, once) — see Course catalogue below. It gives courses a description, difficulty and tags, and it is what the advisor reasons over.

  6. Open the site, create an account, and watch.

Database Schema

CourseHub uses MySQL 8.0+ or MariaDB 10.5+. The schema is applied with:

mysql coursehub < schema.sql

Each table's purpose and columns:

Table Purpose
users User accounts: username, password hash, registration timestamp
folders Directory hierarchy: every folder under VIDEOS_ROOT with parent, depth, sort order, whether it's a course, and which course owns it (nullable for categories)
videos MP4 files found by the scanner: folder they live in, name, path, filesize, duration (filled in by the player on first play), sort order, and whether they're still on disk
quizzes Quiz files (*_quiz_solved.txt): folder they live in, filename, path, question count (parsed from the file), sort order, and whether they're still on disk
catalog_courses The vendor's course catalogue (e.g. INE): vendor uuid, name, slug, folder key (used to match library folders), difficulty, tags, descriptions (short and full), learning paths, categories, duration, and the vendor's page URL. Applied via seeds/ine_catalog.sql, not written by the scanner
course_meta What's known about each library course beyond its folder name: source (sidecar or catalogue), which catalogue id matched it, and all the metadata (title, difficulty, tags, descriptions, paths, categories, duration). Rewritten by every scan from sidecars and the catalogue
video_progress Per-user playback state: position (seconds) and completion flag (sticky at ≥95% watched), with an updated timestamp for cleanup
quiz_progress Per-user quiz results: best score, last score, total questions, attempt count, and a completion flag (sticky when all questions answered correctly in one attempt)
login_attempts Failed sign-in attempts (username + IP): timestamp and count. Used by the login throttle (5 failures per username or 20 per IP in 15 minutes = 429 response). Rows older than an hour are purged; created lazily on first failure
advisor_requests Questions put to the AI course advisor: user, timestamp, whether it succeeded, the model name, and token counts (prompt, cache hit, completion). Created lazily on first use. Kept to track the per-user daily question limit and usage cost
app_meta App-wide key/value store: currently just last_scan_at (the timestamp of the most recent library scan). Created lazily on first scan; reads tolerate its absence

Key relationships and constraints

  • Folders reference themselves (parent_id) and have a course_id pointing to their nearest course ancestor (NULL for top-level categories).
  • Videos and quizzes reference the folder they sit in (folder_id); deleted folders cascade-delete their contents.
  • course_meta rows reference the folder they describe (folder_id); a course has exactly one row or none.
  • catalog_courses is never touched by the scanner; it's applied separately via seeds/ine_catalog.sql and matched to library folders by folder_key.
  • Progress tables (video_progress, quiz_progress, advisor_requests, login_attempts) cascade-delete when a user is deleted.
  • Paths are case-sensitive (utf8mb4_bin collation) to match the filesystem; the rest of the table uses case-insensitive collation. The UNIQUE key on path ensures no duplicates.

Indices

  • folders: parent lookup, course_id grouping for rollups, is_course + is_missing filtering.
  • videos and quizzes: folder lookup, implicit on foreign key constraints.
  • login_attempts: username+time and ip+time for throttling queries.
  • advisor_requests: user+time for daily-limit checks.
  • video_progress: user+time for the periodic progress save beacon.
  • catalog_courses: folder_key for matching against library folders.

Quizzes

Course folders may contain quiz text files next to the videos. Files named *_quiz_solved.txt are indexed (a *_quiz_notsolved.txt twin is ignored — it's the same content without answers). Format:

[&] Which two are supported CLI connectivity methods? (Select two)
- RDP
- rlogin
- GUI widget -- Correct
- FortiExplorer -- Correct

Questions start with [&], options with - , and correct options end with -- Correct. Questions with more than one correct option automatically render as checkboxes instead of radios.

A question that refers to a diagram carries it as a Markdown image on its own line, anywhere between the [&] line and the next question:

[&] Based on the diagram shown, which of the following are true?
![quiz image](https://example.com/quiz-images/switching1.png)
- PE1 imposes a label -- Correct

The image is shown under the question text. It is fetched by the browser from wherever the URL points (usually the course provider's server), so an install with no internet access sees a note and a link instead of the picture.

Quizzes are graded server-side (answers are never sent to the browser before submission). A question counts only if the selected set exactly matches the correct set. Best score, last score, and attempt count are stored per user; a quiz is completed once every question is answered correctly in one attempt, and quiz scores count toward folder progress alongside videos.

Course catalogue

Folder names are all the scanner can see. Two things add more:

  • A sidecar file. The INE downloader (ine.py) drops a <slug>.json into a course folder once every file of the course has been fetched. It carries the course's title, difficulty, tags, description and learning paths, and the scanner reads it.

  • The catalogue seed. The downloader can also export the whole catalogue you have access to (all_courses_with_access.json). Turn it into an SQL patch and apply it to any install, dev or live, before or after a code deploy — it creates its own table and upserts by id, so re-running it is harmless:

    php tools/build-catalog-seed.php ~/ine-dl/all_courses_with_access.json > seeds/ine_catalog.sql
    mysql coursehub < seeds/ine_catalog.sql      # or: sudo mysql coursehub < …
    php scan.php                                  # matches the library to it
    

    seeds/ is git-ignored (the file holds the vendor's course descriptions); ship it alongside the code. Rebuild it whenever the export is refreshed.

Every scan then resolves one course_meta row per course: the sidecar when there is one, otherwise the catalogue entry whose folder key matches the course folder's name. The key is the folder name the downloader would have created — spaces and hyphens become underscores, other punctuation is dropped (folderKeyFor() in includes/functions.php) — so Cisco SD-WAN Use Design, Cloud & Advanced Features finds Cisco_SD_WAN_Use_Design_Cloud_Advanced_Features, and a folder renamed by hand still matches as long as the words are the same. Courses from elsewhere (CBT Nuggets, your own notes) simply have no row and look as they always did. The scan summary and the Settings page say how many courses got their details from a sidecar, from the catalogue, or from neither.

Any other .json under the library (the downloader also writes one per lab lesson) is ignored.

Course advisor

A bubble in the bottom-right corner of every screen — bar the player and a quiz, where it would be in the way — opening a chat panel that answers questions like "I want to learn SD-WAN, where do I start?" or "what should I know before Core MPLS?" with courses from your own library, in a suggested order, each linking to its course page with your progress on it. Courses named in the answer itself are links too. When something you need is not on the server but is in the seeded catalogue, it says so and links to the vendor's page; topics covered nowhere are listed too.

The panel stays open as you move around, so you can follow a recommendation and keep asking. The conversation lives in the browser tab and ends with it.

It is off by default and the app never depends on it. To turn it on, set in config.php:

define('AI_ENABLED', true);
define('AI_API_KEY', 'sk-…');                     // https://platform.deepseek.com/
define('AI_BASE_URL', 'https://api.deepseek.com'); // any OpenAI-format endpoint
define('AI_MODEL', 'deepseek-v4-flash');
define('AI_DAILY_LIMIT', 50);                      // questions per user per day

With AI_ENABLED false (or no key) the bubble does not appear, api/advisor.php answers 503, and the app makes no outbound requests at all. The one extra requirement when it is on is PHP's curl extension. Each user can also hide the button for their browser from Settings.

What leaves the server when it is on: the name, category, difficulty, vendor and size of every course, a short blurb, tags and learning paths for the handful judged relevant to the question, the catalogue titles of courses you have access to but have not downloaded, your progress percentage per course, and the conversation you type. Never video files, section or lesson names, subtitles, or quiz contents.

How it works: every course goes out as a one-line index, which is the same bytes for every user and every question, so the provider's prefix cache covers it and a question costs a fraction of a cent; only the courses that match the question are described in any depth. The model replies in JSON; the server drops any course id it made up before the browser sees it, and links are built only from ids that exist. That applies to the prose as well — an id the model writes inline as #412 is resolved against the same check and becomes a link to that course, and anything that does not resolve is left as plain text.

Each question (including one that fails) counts toward the per-user daily limit. The advisor_requests table records: the user, when the question was asked, whether it succeeded, the model name used, and the token counts (prompt, cache hit, and completion). This lets you audit the advisor's cost and load. The daily limit resets in a rolling window: a question asked at midnight counts toward the limit until midnight tomorrow.

A conversation lives in the browser tab (in sessionStorage under the key coursehub.advisor.chat), survives navigating between pages, and is resent with each turn so follow-ups ("I already know MPLS") work. The conversation is cleared when the user signs out or closes the tab.

Any OpenAI-format chat endpoint can stand in for DeepSeek — point AI_BASE_URL and AI_MODEL at it; a local model server keeps even the advisor offline. If the server rejects DeepSeek's thinking / reasoning_effort fields, add define('AI_REASONING_PARAM', false); to leave them out.

Timeouts: a reply takes a few seconds to tens of seconds. PHP's own limit is raised for the call, but PHP-FPM's request_terminate_timeout, Nginx's fastcgi_read_timeout (60 s by default) and Cloudflare's ~100 s cap still apply — leave them at a minute or more.

The top-bar box searches as you type (from two characters, debounced) and also on Enter. It matches against:

  • Folder names and paths — folders.name and folders.path (case-insensitive despite the utf8mb4_bin collation on path, because the search query is lowercased).
  • Video names and paths — videos.name and videos.path.
  • Quiz names and paths — quizzes.name and quizzes.path.
  • Course metadata — once the catalogue is seeded, the course_meta table's title, tags (JSON array, searched as text), and description fields are indexed.

Results are grouped into courses/sections, videos and quizzes, with your progress (from video_progress and quiz_progress tables) shown on each row. Searches live at #/search/<query>, so a result page can be bookmarked or shared. Only items with is_missing = 0 are shown (files that still exist on disk).

Resetting progress

api/reset.php deletes progress rows for the logged-in user only — never for anyone else, and never any video, quiz or folder itself. Four scopes:

Where Scope Clears
Player page → Reset video that video's position + completion
Quiz page → Reset attempts quiz that quiz's attempts, best/last score
Folder page → Reset progress folder everything in that folder's subtree
Library list row → ↺ folder that one course's subtree
Settings / avatar menu → Reset all progress all every video and quiz on the account

Each one asks for confirmation first, and the reply reports how many video and quiz rows were removed. Resetting the video you are watching also suppresses the player's teardown save, so the position you just cleared isn't written back.

Look and feel

The UI implements the Course Library design (a Claude Design document). assets/style.css is the whole design system — tokens first, then layout, then components. There is no CSS framework under it, and the icons are inline SVG from the design rather than an icon font.

Token Hex Used for
Paper #EFEAE1 outermost ground
Surface #FAF8F5 app background
Sidebar #F5F1EA left rail
Card #FFFFFF cards, rows, dialogs
Ink #1A1814 → #B8B0A2 four text weights (--ink … --ink-4)
Accent #1F6F5C primary action, links, progress, completion
Danger #B84A3C reset and errors

Type: Inter throughout — headings, interface text, and counts/durations/ paths (the design's own 'Elms Sans' ships only inside the design document). The font is self-hosted from assets/vendor/ (latin + latin-ext subsets). Numeric text uses tabular-nums so columns of counts and durations still line up without a separate monospace face.

Courses have no cover art, so each one gets a hue from the design's six-colour set (clay, sage, slate, sand, plum, sky), picked from its folder id so it stays the same across renders and reloads. That hue drives its card art, its initial badge and its progress bar.

There is no scripted motion — views render directly in their final state; the only movement is the CSS the components themselves carry (hover states, the loading spinner).

Below 900 px the layout follows the design's mobile frame: the sidebar gives way to a bottom tab bar, the search field becomes a pill with a round scan button next to it, and grids drop to two columns.

Settings

#/settings shows what the server actually knows and what this browser remembers:

  • Library folder — VIDEOS_ROOT, whether it is readable, indexed counts (from folders, videos, quizzes tables with is_missing = 0), total running time (summed from videos.duration_seconds), the watched threshold (COMPLETE_THRESHOLD, defaults to 95%), and when the last scan ran (from app_meta.last_scan_at).
  • Course metadata — how many courses have descriptions (from course_meta table), how many came from a sidecar JSON (source = 'sidecar'), and how many from the catalogue (source = 'catalog').
  • Course advisor — enabled or off, the model name if on, how many questions you've asked today (count from advisor_requests in the rolling 24-hour window), and the daily limit.
  • Preferences, stored per browser (in localStorage): autoplay next video, open the library in list view, and whether to show the advisor button.
  • Reset progress — the account-wide reset, with a count of how many courses currently hold progress (from video_progress and quiz_progress grouped by course).

The scan timestamp lives in the app_meta key/value table under the key last_scan_at. Reads tolerate the table not existing, so an install created before it was added keeps working until its next scan creates it.

How progress works

  • The player POSTs {video_id, position, duration} to api/progress.php every 5 s while playing, plus on pause/seek, via navigator.sendBeacon when the tab is hidden or closed, and when navigating to another view in the SPA. These updates write to the video_progress table, one row per user per video, with the position (seconds), reported duration, completion flag, and an updated timestamp.
  • A video is marked completed (in video_progress.completed) at ≥ 95 % watched (COMPLETE_THRESHOLD). Completion is sticky — rewatching doesn't un-complete it. The same logic applies to quizzes: once quiz_progress.completed is true (every question answered correctly in one attempt), it stays true.
  • Folder progress is computed over the folder's entire subtree using a path prefix match against folders.path, with partial credit for videos in progress. This calculation spans both video_progress and quiz_progress tables.
  • Querying for progress is optimized: every course is rolled up in two queries for the whole library (one for videos, one for quizzes), grouped by folders.course_id, and a folder's children in two more — not two per card. A library of a few hundred courses would otherwise spend a page load on several hundred prefix-matched queries. See courseRollups() and advisorProgress() in includes/functions.php.
  • Video duration is read at scan time by parsing the mp4's mvhd header in pure PHP, so listings show durations before anyone plays the video. The duration is stored in videos.duration_seconds. If a file can't be parsed at scan time, the duration_seconds is NULL and the browser reports its duration on first play, updating video_progress.duration_seconds for that user.

When something breaks

index.php stamps assets/*.css|js with the file's mtime (asset()), so a deploy is always a fresh URL. Without that, a browser or CDN holding a stale app.js runs it against a newer shell, it grabs element IDs that no longer exist, and it throws before unhiding the shell — a blank page whose only clue is in the console. If you ever do see a blank page, hard-refresh and purge the CDN cache first.

A small handler installed ahead of every other script catches uncaught errors, unhandled rejections and failed script/stylesheet loads, and paints them into a #boot-error panel — so a failure that stops the app is visible on the page rather than silent. Server-side, uncaught exceptions and fatals (via register_shutdown_function, which set_exception_handler never sees) are returned as JSON, with detail gated behind DEBUG.

Security

  • Sessions: cookies are set HttpOnly + SameSite=Lax in code, Secure automatically when the request came over HTTPS, and strict session-ID mode is on (fixation protection).
  • CSRF: every state-changing request must echo the session's token — fetch() sends it as an X-CSRF-Token header; the sendBeacon progress save carries it as a csrf body field. The token is issued by GET api/auth.php and rotated at login.
  • Login throttling: failed sign-ins are recorded per username + IP (login_attempts, created automatically on first failure, so existing installs need no migration). Five failures for a username, or twenty for an IP, inside 15 minutes answer 429 until 15 minutes have passed since the last failure. A successful login clears the username's failures; stale rows are purged as new ones are recorded.
  • Behind Cloudflare (or any proxy that hides the visitor), the IP the throttle sees is the proxy's. Either restore real client IPs at the web server (Apache mod_remoteip / Nginx real_ip with CF-Connecting-IP — the better fix, it also corrects your logs), or set CLIENT_IP_HEADER to 'CF-Connecting-IP' in config.php. Only do either if the origin is unreachable except through the proxy; otherwise the header can be spoofed to dodge the per-IP limit.
  • The shipped .htaccess (Apache) denies direct access to videos/, includes/, seeds/, tools/, config.php and schema.sql — without it, video URLs would bypass login entirely. The rules match those folder names anywhere in the URL path, so they hold whether the app lives at / or under a prefix such as /coursehub/. Nginx setups need the equivalent location blocks, or keep the videos and includes outside the docroot (the safest choice either way).
  • The server makes no outbound requests unless the AI advisor is switched on, and then only to AI_BASE_URL (see Course advisor).
  • Only .mp4 files ever reach the browser, and only through stream.php. A quiz's exhibit image is the one thing loaded from elsewhere — its URL is whatever the quiz file says, sent with referrerpolicy="no-referrer".

Production notes

  • PHP streams the video in 512 KB chunks with Range support, which is fine for a handful of users. For heavier use, serve files with X-Accel-Redirect (Nginx) or X-Sendfile (Apache) instead of streaming through PHP.
  • Put the app behind HTTPS — the session cookie is then marked Secure on its own. When TLS ends at a proxy that talks plain HTTP to the origin, the cookie is still marked Secure provided CLIENT_IP_HEADER is set: the same trust that makes the proxy's client-IP header believable makes its X-Forwarded-Proto believable.
  • A rescan is one-at-a-time: a second Scan library while one is running (from any user) answers "already running" instead of racing it.