-- CourseHub schema (MySQL / MariaDB) CREATE DATABASE IF NOT EXISTS coursehub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE coursehub; CREATE TABLE IF NOT EXISTS users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; -- Failed sign-in attempts, for the login brute-force throttle. Rows older -- than an hour are purged as new failures are recorded. Existing installs -- get this table lazily on the first failed login. CREATE TABLE IF NOT EXISTS login_attempts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, ip VARCHAR(45) NOT NULL, attempted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_la_user_time (username, attempted_at), INDEX idx_la_ip_time (ip, attempted_at) ) ENGINE=InnoDB; -- Small key/value store for app-wide facts (currently: when the last scan ran). CREATE TABLE IF NOT EXISTS app_meta ( k VARCHAR(64) PRIMARY KEY, v VARCHAR(255) NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB; -- One row per directory found under VIDEOS_ROOT. -- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro". -- -- Paths are utf8mb4_bin: they identify a file on a case-sensitive filesystem, -- so "CCNP Notes" and "CCNP NOtes" are two directories, not one. Under the -- table's default (case-insensitive) collation the UNIQUE key merges them and -- the second tree silently overwrites the first. -- -- `is_course` marks the folder the library shows as a course. Course roots sit -- at whatever depth the material happens to use — a top-level folder may be a -- category (Networking/Cisco/) or a course in its own right — so the -- scanner works it out per branch rather than assuming a fixed depth; see -- markCourseRoots(). `course_id` is the nearest course at-or-above the folder -- (NULL for the categories above every course), which is what makes a course's -- rollup one grouped query instead of one query per course. CREATE TABLE IF NOT EXISTS folders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, parent_id INT UNSIGNED NULL, name VARCHAR(255) NOT NULL, path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE, depth INT UNSIGNED NOT NULL DEFAULT 0, sort_order INT UNSIGNED NOT NULL DEFAULT 0, is_course TINYINT(1) NOT NULL DEFAULT 0, course_id INT UNSIGNED NULL, is_missing TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE, INDEX idx_folders_parent (parent_id), INDEX idx_folders_course (course_id), INDEX idx_folders_is_course (is_course, is_missing) ) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS videos ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, folder_id INT UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL, -- display name, filename without .mp4 filename VARCHAR(255) NOT NULL, path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE, -- relative path incl. filename size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, duration_seconds FLOAT NULL, -- filled in by the player on first load sort_order INT UNSIGNED NOT NULL DEFAULT 0, is_missing TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_videos_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE, INDEX idx_videos_folder (folder_id) ) ENGINE=InnoDB; -- One row per "*_quiz_solved.txt" file found next to the videos. -- Questions/options are parsed from the file on demand; only the count is -- cached here for progress math. CREATE TABLE IF NOT EXISTS quizzes ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, folder_id INT UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL, -- filename without the _quiz_solved.txt suffix filename VARCHAR(255) NOT NULL, path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE, num_questions INT UNSIGNED NOT NULL DEFAULT 0, sort_order INT UNSIGNED NOT NULL DEFAULT 0, is_missing TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_quizzes_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE, INDEX idx_quizzes_folder (folder_id) ) ENGINE=InnoDB; -- The vendor's course catalogue (an INE export), applied by seeds/ine_catalog.sql -- (built with tools/build-catalog-seed.php) — never written by a scan. -- `folder_key` is the folder name the downloader creates for a course (see -- folderKeyFor() in includes/functions.php), which is how a library folder is -- matched to its catalogue entry. CREATE TABLE IF NOT EXISTS catalog_courses ( id VARCHAR(64) PRIMARY KEY, -- vendor uuid name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, folder_key VARCHAR(255) NOT NULL, vendor VARCHAR(100) NULL, difficulty VARCHAR(32) NULL, tags TEXT NULL, -- JSON array of strings short_description TEXT NULL, description TEXT NULL, learning_paths TEXT NULL, -- JSON array of names categories TEXT NULL, -- JSON array of names duration_seconds INT UNSIGNED NULL, url VARCHAR(500) NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_cc_folder_key (folder_key) ) ENGINE=InnoDB; -- What is known about one library course beyond its folder name: taken from -- the sidecar JSON the downloader leaves in a fully downloaded course, else -- from catalog_courses by folder name. Rewritten by every scan. CREATE TABLE IF NOT EXISTS course_meta ( folder_id INT UNSIGNED PRIMARY KEY, source ENUM('sidecar', 'catalog') NOT NULL, catalog_id VARCHAR(64) NULL, title VARCHAR(255) NULL, vendor VARCHAR(100) NULL, difficulty VARCHAR(32) NULL, tags TEXT NULL, short_description TEXT NULL, description TEXT NULL, learning_paths TEXT NULL, categories TEXT NULL, duration_seconds INT UNSIGNED NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_cm_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE ) ENGINE=InnoDB; -- One row per question put to the AI course advisor: the per-user daily cap -- and a record of what it cost. Created lazily on first use, like login_attempts. CREATE TABLE IF NOT EXISTS advisor_requests ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, ok TINYINT(1) NOT NULL DEFAULT 0, model VARCHAR(64) NULL, prompt_tokens INT UNSIGNED NULL, cache_hit_tokens INT UNSIGNED NULL, completion_tokens INT UNSIGNED NULL, INDEX idx_ar_user_time (user_id, created_at), CONSTRAINT fk_ar_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB; -- Per-user quiz results. completed = every question answered correctly -- in a single attempt (sticky). CREATE TABLE IF NOT EXISTS quiz_progress ( user_id INT UNSIGNED NOT NULL, quiz_id INT UNSIGNED NOT NULL, best_score INT UNSIGNED NOT NULL DEFAULT 0, last_score INT UNSIGNED NOT NULL DEFAULT 0, total_questions INT UNSIGNED NOT NULL DEFAULT 0, attempts INT UNSIGNED NOT NULL DEFAULT 0, completed TINYINT(1) NOT NULL DEFAULT 0, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, quiz_id), CONSTRAINT fk_qp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_qp_quiz FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE ) ENGINE=InnoDB; -- Per-user playback state: resume position + completion flag. CREATE TABLE IF NOT EXISTS video_progress ( user_id INT UNSIGNED NOT NULL, video_id INT UNSIGNED NOT NULL, position_seconds FLOAT NOT NULL DEFAULT 0, duration_seconds FLOAT NULL, completed TINYINT(1) NOT NULL DEFAULT 0, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, video_id), CONSTRAINT fk_vp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_vp_video FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE, INDEX idx_vp_user_updated (user_id, updated_at) ) ENGINE=InnoDB;