-- 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; -- 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;