coursewatcher 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Video Player Controls
3
+ *
4
+ * Handles keyboard shortcuts, playback controls, and progress auto-save.
5
+ */
6
+
7
+ (function () {
8
+ 'use strict';
9
+
10
+ // Constants
11
+ const SPEED_STEP = 0.25;
12
+ const MIN_SPEED = 0.25;
13
+ const MAX_SPEED = 4.0;
14
+ const SEEK_SHORT = 5;
15
+ const SEEK_MEDIUM = 10;
16
+ const SEEK_LONG = 30;
17
+ const SAVE_INTERVAL = 5000; // Save progress every 5 seconds
18
+
19
+ // Elements
20
+ const video = document.getElementById('videoPlayer');
21
+ const speedDisplay = document.getElementById('speedDisplay');
22
+ const speedDown = document.getElementById('speedDown');
23
+ const speedUp = document.getElementById('speedUp');
24
+ const seekBack5 = document.getElementById('seekBack5');
25
+ const seekForward5 = document.getElementById('seekForward5');
26
+ const seekForward10 = document.getElementById('seekForward10');
27
+ const statusButtons = document.querySelectorAll('.status-btn');
28
+ const notesEditor = document.getElementById('notesEditor');
29
+ const saveNotesBtn = document.getElementById('saveNotes');
30
+ const notesSaveStatus = document.getElementById('notesSaveStatus');
31
+
32
+ if (!video) return;
33
+
34
+ const videoId = video.dataset.videoId;
35
+ const savedPosition = parseFloat(video.dataset.savedPosition) || 0;
36
+
37
+ let saveTimeout = null;
38
+ let lastSavedPosition = savedPosition;
39
+
40
+ // ==========================================
41
+ // Initialization
42
+ // ==========================================
43
+
44
+ /**
45
+ * Initialize player
46
+ */
47
+ function init() {
48
+ // Restore saved position
49
+ video.addEventListener('loadedmetadata', () => {
50
+ if (savedPosition > 0 && savedPosition < video.duration) {
51
+ video.currentTime = savedPosition;
52
+ }
53
+ updateSpeedDisplay();
54
+ });
55
+
56
+ // Set up event listeners
57
+ setupPlaybackControls();
58
+ setupStatusControls();
59
+ setupKeyboardShortcuts();
60
+ setupNotesControls();
61
+ setupProgressAutoSave();
62
+ }
63
+
64
+ // ==========================================
65
+ // Playback Controls
66
+ // ==========================================
67
+
68
+ function setupPlaybackControls() {
69
+ speedDown?.addEventListener('click', () => changeSpeed(-SPEED_STEP));
70
+ speedUp?.addEventListener('click', () => changeSpeed(SPEED_STEP));
71
+ seekBack5?.addEventListener('click', () => seek(-SEEK_SHORT));
72
+ seekForward5?.addEventListener('click', () => seek(SEEK_SHORT));
73
+ seekForward10?.addEventListener('click', () => seek(SEEK_MEDIUM));
74
+ }
75
+
76
+ function changeSpeed(delta) {
77
+ let newSpeed = video.playbackRate + delta;
78
+ newSpeed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
79
+ newSpeed = Math.round(newSpeed * 100) / 100; // Fix floating point
80
+ video.playbackRate = newSpeed;
81
+ updateSpeedDisplay();
82
+ }
83
+
84
+ function updateSpeedDisplay() {
85
+ if (speedDisplay) {
86
+ speedDisplay.textContent = video.playbackRate.toFixed(2) + 'x';
87
+ }
88
+ }
89
+
90
+ function seek(seconds) {
91
+ video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + seconds));
92
+ }
93
+
94
+ // ==========================================
95
+ // Status Controls
96
+ // ==========================================
97
+
98
+ function setupStatusControls() {
99
+ statusButtons.forEach(btn => {
100
+ btn.addEventListener('click', () => {
101
+ const status = btn.dataset.status;
102
+ updateStatus(status);
103
+ });
104
+ });
105
+ }
106
+
107
+ async function updateStatus(status) {
108
+ try {
109
+ const response = await fetch(`/api/videos/${videoId}/status`, {
110
+ method: 'POST',
111
+ headers: { 'Content-Type': 'application/json' },
112
+ body: JSON.stringify({ status }),
113
+ });
114
+
115
+ if (response.ok) {
116
+ // Update UI
117
+ statusButtons.forEach(btn => {
118
+ btn.classList.toggle('active', btn.dataset.status === status);
119
+ });
120
+ }
121
+ } catch (err) {
122
+ console.error('Failed to update status:', err);
123
+ }
124
+ }
125
+
126
+ // ==========================================
127
+ // Keyboard Shortcuts
128
+ // ==========================================
129
+
130
+ function setupKeyboardShortcuts() {
131
+ document.addEventListener('keydown', (e) => {
132
+ // Ignore if typing in notes
133
+ if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') {
134
+ return;
135
+ }
136
+
137
+ switch (e.key.toLowerCase()) {
138
+ case ' ':
139
+ e.preventDefault();
140
+ video.paused ? video.play() : video.pause();
141
+ break;
142
+ case 'w':
143
+ changeSpeed(-SPEED_STEP);
144
+ break;
145
+ case 'e':
146
+ changeSpeed(SPEED_STEP);
147
+ break;
148
+ case 'j':
149
+ seek(e.shiftKey ? -SEEK_LONG : -SEEK_SHORT);
150
+ break;
151
+ case 'k':
152
+ seek(SEEK_SHORT);
153
+ break;
154
+ case 'l':
155
+ seek(e.shiftKey ? SEEK_LONG : SEEK_MEDIUM);
156
+ break;
157
+ case 'arrowleft':
158
+ seek(-SEEK_SHORT);
159
+ break;
160
+ case 'arrowright':
161
+ seek(SEEK_SHORT);
162
+ break;
163
+ case 'arrowup':
164
+ e.preventDefault();
165
+ video.volume = Math.min(1, video.volume + 0.1);
166
+ break;
167
+ case 'arrowdown':
168
+ e.preventDefault();
169
+ video.volume = Math.max(0, video.volume - 0.1);
170
+ break;
171
+ case 'm':
172
+ video.muted = !video.muted;
173
+ break;
174
+ case 'f':
175
+ toggleFullscreen();
176
+ break;
177
+ }
178
+ });
179
+ }
180
+
181
+ function toggleFullscreen() {
182
+ if (document.fullscreenElement) {
183
+ document.exitFullscreen();
184
+ } else {
185
+ video.requestFullscreen?.() || video.webkitRequestFullscreen?.();
186
+ }
187
+ }
188
+
189
+ // ==========================================
190
+ // Progress Auto-Save
191
+ // ==========================================
192
+
193
+ function setupProgressAutoSave() {
194
+ // Save on pause
195
+ video.addEventListener('pause', saveProgress);
196
+
197
+ // Save on video end
198
+ video.addEventListener('ended', () => {
199
+ saveProgress();
200
+ updateStatus('completed');
201
+ });
202
+
203
+ // Periodic save during playback
204
+ video.addEventListener('timeupdate', () => {
205
+ const currentPos = Math.floor(video.currentTime);
206
+
207
+ // Only save every 5 seconds of playback
208
+ if (Math.abs(currentPos - lastSavedPosition) >= 5) {
209
+ scheduleProgressSave();
210
+ }
211
+ });
212
+
213
+ // Save before page unload
214
+ window.addEventListener('beforeunload', () => {
215
+ saveProgressSync();
216
+ });
217
+ }
218
+
219
+ function scheduleProgressSave() {
220
+ if (saveTimeout) return;
221
+
222
+ saveTimeout = setTimeout(() => {
223
+ saveProgress();
224
+ saveTimeout = null;
225
+ }, 1000);
226
+ }
227
+
228
+ async function saveProgress() {
229
+ const position = video.currentTime;
230
+ const duration = video.duration;
231
+
232
+ if (isNaN(position) || isNaN(duration)) return;
233
+
234
+ try {
235
+ await fetch(`/api/videos/${videoId}/progress`, {
236
+ method: 'POST',
237
+ headers: { 'Content-Type': 'application/json' },
238
+ body: JSON.stringify({ position, duration }),
239
+ });
240
+ lastSavedPosition = position;
241
+ } catch (err) {
242
+ console.error('Failed to save progress:', err);
243
+ }
244
+ }
245
+
246
+ function saveProgressSync() {
247
+ const position = video.currentTime;
248
+ const duration = video.duration;
249
+
250
+ if (isNaN(position) || isNaN(duration)) return;
251
+
252
+ // Use sendBeacon for reliable delivery on page unload
253
+ navigator.sendBeacon(
254
+ `/api/videos/${videoId}/progress`,
255
+ new Blob([JSON.stringify({ position, duration })], { type: 'application/json' })
256
+ );
257
+ }
258
+
259
+ // ==========================================
260
+ // Notes Controls
261
+ // ==========================================
262
+
263
+ function setupNotesControls() {
264
+ if (!saveNotesBtn || !notesEditor) return;
265
+
266
+ saveNotesBtn.addEventListener('click', saveNotes);
267
+
268
+ // Auto-save notes on blur
269
+ notesEditor.addEventListener('blur', saveNotes);
270
+
271
+ // Ctrl+S to save notes
272
+ notesEditor.addEventListener('keydown', (e) => {
273
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
274
+ e.preventDefault();
275
+ saveNotes();
276
+ }
277
+ });
278
+ }
279
+
280
+ async function saveNotes() {
281
+ const content = notesEditor.value;
282
+
283
+ try {
284
+ const response = await fetch(`/api/videos/${videoId}/notes`, {
285
+ method: 'POST',
286
+ headers: { 'Content-Type': 'application/json' },
287
+ body: JSON.stringify({ content }),
288
+ });
289
+
290
+ if (response.ok) {
291
+ showSaveStatus('✓ Saved');
292
+ } else {
293
+ showSaveStatus('✗ Error saving');
294
+ }
295
+ } catch (err) {
296
+ console.error('Failed to save notes:', err);
297
+ showSaveStatus('✗ Error saving');
298
+ }
299
+ }
300
+
301
+ function showSaveStatus(message) {
302
+ if (!notesSaveStatus) return;
303
+
304
+ notesSaveStatus.textContent = message;
305
+ setTimeout(() => {
306
+ notesSaveStatus.textContent = '';
307
+ }, 2000);
308
+ }
309
+
310
+ // Initialize when DOM is ready
311
+ if (document.readyState === 'loading') {
312
+ document.addEventListener('DOMContentLoaded', init);
313
+ } else {
314
+ init();
315
+ }
316
+ })();
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Video Controller
3
+ *
4
+ * Route handlers for video-related pages and API endpoints.
5
+ *
6
+ * @module controllers/video-controller
7
+ */
8
+
9
+ const path = require('path');
10
+ const express = require('express');
11
+ const { NotFoundError } = require('../utils/errors');
12
+
13
+ /**
14
+ * Create video routes
15
+ * @param {Object} services - Service instances
16
+ * @returns {express.Router} Express router
17
+ */
18
+ function createVideoRoutes(services) {
19
+ const router = express.Router();
20
+ const { videoService, progressService, notesService } = services;
21
+
22
+ // ============================================
23
+ // Page Routes
24
+ // ============================================
25
+
26
+ /**
27
+ * Home page - List all modules and videos
28
+ */
29
+ router.get('/', (req, res) => {
30
+ const sortBy = req.query.sort || 'name';
31
+ const modules = videoService.getAllModulesWithVideos(sortBy);
32
+ const stats = videoService.getStats();
33
+
34
+ res.render('pages/index', {
35
+ title: 'CourseWatcher',
36
+ modules,
37
+ stats,
38
+ currentSort: sortBy,
39
+ });
40
+ });
41
+
42
+ /**
43
+ * Video player page
44
+ */
45
+ router.get('/video/:id', (req, res, next) => {
46
+ try {
47
+ const videoId = parseInt(req.params.id, 10);
48
+ const video = videoService.getVideoById(videoId);
49
+ const adjacent = videoService.getAdjacentVideos(videoId);
50
+ const notes = notesService.getNotes(videoId);
51
+
52
+ res.render('pages/player', {
53
+ title: video.title,
54
+ video,
55
+ adjacent,
56
+ notes,
57
+ });
58
+ } catch (err) {
59
+ next(err);
60
+ }
61
+ });
62
+
63
+ /**
64
+ * Search page
65
+ */
66
+ router.get('/search', (req, res) => {
67
+ const query = req.query.q || '';
68
+ const results = query ? videoService.searchVideos(query) : [];
69
+
70
+ res.render('pages/search', {
71
+ title: 'Search',
72
+ query,
73
+ results,
74
+ });
75
+ });
76
+
77
+ // ============================================
78
+ // API Routes
79
+ // ============================================
80
+
81
+ /**
82
+ * Stream video file
83
+ */
84
+ router.get('/api/videos/:id/stream', (req, res, next) => {
85
+ try {
86
+ const videoId = parseInt(req.params.id, 10);
87
+ const video = videoService.getVideoById(videoId);
88
+
89
+ res.sendFile(video.path);
90
+ } catch (err) {
91
+ next(err);
92
+ }
93
+ });
94
+
95
+ /**
96
+ * Update video progress (position)
97
+ */
98
+ router.post('/api/videos/:id/progress', express.json(), (req, res, next) => {
99
+ try {
100
+ const videoId = parseInt(req.params.id, 10);
101
+ const { position, duration } = req.body;
102
+
103
+ const updated = progressService.updatePosition(videoId, position, duration);
104
+ res.json({ success: true, video: updated });
105
+ } catch (err) {
106
+ next(err);
107
+ }
108
+ });
109
+
110
+ /**
111
+ * Update video status
112
+ */
113
+ router.post('/api/videos/:id/status', express.json(), (req, res, next) => {
114
+ try {
115
+ const videoId = parseInt(req.params.id, 10);
116
+ const { status } = req.body;
117
+
118
+ const updated = progressService.updateStatus(videoId, status);
119
+ res.json({ success: true, video: updated });
120
+ } catch (err) {
121
+ next(err);
122
+ }
123
+ });
124
+
125
+ /**
126
+ * Get video notes
127
+ */
128
+ router.get('/api/videos/:id/notes', (req, res, next) => {
129
+ try {
130
+ const videoId = parseInt(req.params.id, 10);
131
+ const notes = notesService.getNotes(videoId);
132
+ res.json(notes);
133
+ } catch (err) {
134
+ next(err);
135
+ }
136
+ });
137
+
138
+ /**
139
+ * Save video notes
140
+ */
141
+ router.post('/api/videos/:id/notes', express.json(), (req, res, next) => {
142
+ try {
143
+ const videoId = parseInt(req.params.id, 10);
144
+ const { content } = req.body;
145
+
146
+ const notes = notesService.saveNotes(videoId, content);
147
+ res.json({ success: true, notes });
148
+ } catch (err) {
149
+ next(err);
150
+ }
151
+ });
152
+
153
+ /**
154
+ * Search API endpoint
155
+ */
156
+ router.get('/api/search', (req, res) => {
157
+ const query = req.query.q || '';
158
+ const results = query ? videoService.searchVideos(query) : [];
159
+ res.json(results);
160
+ });
161
+
162
+ /**
163
+ * Get course stats
164
+ */
165
+ router.get('/api/stats', (req, res) => {
166
+ const stats = videoService.getStats();
167
+ res.json(stats);
168
+ });
169
+
170
+ return router;
171
+ }
172
+
173
+ module.exports = { createVideoRoutes };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Database Module
3
+ *
4
+ * SQLite database connection manager using better-sqlite3.
5
+ * Handles schema creation and provides query methods.
6
+ *
7
+ * @module models/database
8
+ */
9
+
10
+ const Database = require('better-sqlite3');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const { getDbPath, getDataFolder } = require('../utils/config');
14
+ const { DatabaseError } = require('../utils/errors');
15
+
16
+ /**
17
+ * Database manager class
18
+ */
19
+ class DatabaseManager {
20
+ /**
21
+ * Create a new DatabaseManager
22
+ * @param {string} coursePath - Path to the course directory
23
+ */
24
+ constructor(coursePath) {
25
+ this._coursePath = coursePath;
26
+ this._db = null;
27
+ }
28
+
29
+ /**
30
+ * Initialize the database connection and create schema
31
+ * @returns {DatabaseManager} This instance for chaining
32
+ */
33
+ initialize() {
34
+ try {
35
+ // Ensure .coursewatcher folder exists
36
+ const dataFolder = getDataFolder(this._coursePath);
37
+ if (!fs.existsSync(dataFolder)) {
38
+ fs.mkdirSync(dataFolder, { recursive: true });
39
+ }
40
+
41
+ // Open database connection
42
+ const dbPath = getDbPath(this._coursePath);
43
+ this._db = new Database(dbPath);
44
+
45
+ // Enable foreign keys
46
+ this._db.pragma('foreign_keys = ON');
47
+
48
+ // Create schema
49
+ this._createSchema();
50
+
51
+ return this;
52
+ } catch (err) {
53
+ throw new DatabaseError(`Failed to initialize database: ${err.message}`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Create database schema
59
+ * @private
60
+ */
61
+ _createSchema() {
62
+ // Modules table
63
+ this._db.exec(`
64
+ CREATE TABLE IF NOT EXISTS modules (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ name TEXT NOT NULL,
67
+ path TEXT NOT NULL UNIQUE,
68
+ sort_order INTEGER DEFAULT 0,
69
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
70
+ )
71
+ `);
72
+
73
+ // Videos table
74
+ this._db.exec(`
75
+ CREATE TABLE IF NOT EXISTS videos (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ path TEXT NOT NULL UNIQUE,
78
+ filename TEXT NOT NULL,
79
+ title TEXT NOT NULL,
80
+ duration REAL DEFAULT 0,
81
+ position REAL DEFAULT 0,
82
+ status TEXT CHECK(status IN ('unwatched', 'in-progress', 'completed')) DEFAULT 'unwatched',
83
+ module_id INTEGER,
84
+ sort_order INTEGER DEFAULT 0,
85
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
86
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
87
+ FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE SET NULL
88
+ )
89
+ `);
90
+
91
+ // Notes table
92
+ this._db.exec(`
93
+ CREATE TABLE IF NOT EXISTS notes (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ video_id INTEGER NOT NULL UNIQUE,
96
+ content TEXT DEFAULT '',
97
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
98
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
99
+ FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
100
+ )
101
+ `);
102
+
103
+ // Create indexes
104
+ this._db.exec(`
105
+ CREATE INDEX IF NOT EXISTS idx_videos_module ON videos(module_id);
106
+ CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status);
107
+ CREATE INDEX IF NOT EXISTS idx_videos_path ON videos(path);
108
+ `);
109
+ }
110
+
111
+ /**
112
+ * Get a single row from a query
113
+ * @param {string} sql - SQL query
114
+ * @param {Array} params - Query parameters
115
+ * @returns {Object|undefined} The row or undefined
116
+ */
117
+ get(sql, params = []) {
118
+ return this._db.prepare(sql).get(...params);
119
+ }
120
+
121
+ /**
122
+ * Get all rows from a query
123
+ * @param {string} sql - SQL query
124
+ * @param {Array} params - Query parameters
125
+ * @returns {Array} Array of rows
126
+ */
127
+ all(sql, params = []) {
128
+ return this._db.prepare(sql).all(...params);
129
+ }
130
+
131
+ /**
132
+ * Run an insert/update/delete query
133
+ * @param {string} sql - SQL query
134
+ * @param {Array} params - Query parameters
135
+ * @returns {Object} Result with changes and lastInsertRowid
136
+ */
137
+ run(sql, params = []) {
138
+ return this._db.prepare(sql).run(...params);
139
+ }
140
+
141
+ /**
142
+ * Run multiple statements in a transaction
143
+ * @param {Function} fn - Function containing database operations
144
+ * @returns {*} Result of the transaction function
145
+ */
146
+ transaction(fn) {
147
+ return this._db.transaction(fn)();
148
+ }
149
+
150
+ /**
151
+ * Close the database connection
152
+ */
153
+ close() {
154
+ if (this._db) {
155
+ this._db.close();
156
+ this._db = null;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Get the course path
162
+ * @returns {string} The course path
163
+ */
164
+ getCoursePath() {
165
+ return this._coursePath;
166
+ }
167
+ }
168
+
169
+ module.exports = { DatabaseManager };