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.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/bin/coursewatcher.js +43 -0
- package/package.json +57 -0
- package/public/css/styles.css +935 -0
- package/public/js/player.js +316 -0
- package/src/controllers/video-controller.js +173 -0
- package/src/models/database.js +169 -0
- package/src/server.js +152 -0
- package/src/services/notes-service.js +97 -0
- package/src/services/progress-service.js +148 -0
- package/src/services/video-service.js +322 -0
- package/src/utils/config.js +56 -0
- package/src/utils/errors.js +57 -0
- package/src/utils/logger.js +48 -0
- package/views/layouts/main.ejs +13 -0
- package/views/pages/error.ejs +25 -0
- package/views/pages/index.ejs +101 -0
- package/views/pages/player.ejs +118 -0
- package/views/pages/search.ejs +63 -0
- package/views/partials/footer.ejs +3 -0
- package/views/partials/head.ejs +8 -0
- package/views/partials/header.ejs +14 -0
- package/views/partials/video-card.ejs +36 -0
|
@@ -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 };
|