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,322 @@
1
+ /**
2
+ * Video Service
3
+ *
4
+ * Handles video discovery, scanning, and CRUD operations.
5
+ * Groups videos into modules based on folder structure.
6
+ *
7
+ * @module services/video-service
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { config } = require('../utils/config');
13
+ const { NotFoundError } = require('../utils/errors');
14
+
15
+ /**
16
+ * Video service class
17
+ */
18
+ class VideoService {
19
+ /**
20
+ * Create a new VideoService
21
+ * @param {DatabaseManager} database - Database manager instance
22
+ */
23
+ constructor(database) {
24
+ this._db = database;
25
+ }
26
+
27
+ /**
28
+ * Scan the course directory for video files
29
+ * @returns {Object} Scan results with counts
30
+ */
31
+ scanVideos() {
32
+ const coursePath = this._db.getCoursePath();
33
+ const videos = this._findVideoFiles(coursePath);
34
+
35
+ let addedCount = 0;
36
+ let updatedCount = 0;
37
+
38
+ this._db.transaction(() => {
39
+ for (const video of videos) {
40
+ const existing = this._db.get(
41
+ 'SELECT id FROM videos WHERE path = ?',
42
+ [video.path]
43
+ );
44
+
45
+ if (existing) {
46
+ // Update title in case the extraction logic changed
47
+ this._db.run(
48
+ 'UPDATE videos SET title = ? WHERE id = ?',
49
+ [video.title, existing.id]
50
+ );
51
+ updatedCount++;
52
+ } else {
53
+ // Get or create module
54
+ const moduleId = this._getOrCreateModule(video.modulePath, video.moduleName);
55
+
56
+ // Insert video
57
+ this._db.run(
58
+ `INSERT INTO videos (path, filename, title, module_id, sort_order)
59
+ VALUES (?, ?, ?, ?, ?)`,
60
+ [video.path, video.filename, video.title, moduleId, video.sortOrder]
61
+ );
62
+ addedCount++;
63
+ }
64
+ }
65
+ });
66
+
67
+ return {
68
+ total: videos.length,
69
+ added: addedCount,
70
+ existing: updatedCount,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Find all video files in a directory recursively
76
+ * @param {string} dir - Directory to scan
77
+ * @param {string} [relativeTo] - Base path for relative paths
78
+ * @returns {Array} Array of video info objects
79
+ * @private
80
+ */
81
+ _findVideoFiles(dir, relativeTo = dir) {
82
+ const videos = [];
83
+
84
+ try {
85
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
86
+
87
+ for (const entry of entries) {
88
+ const fullPath = path.join(dir, entry.name);
89
+
90
+ // Skip .coursewatcher folder
91
+ if (entry.name === '.coursewatcher') continue;
92
+
93
+ if (entry.isDirectory()) {
94
+ videos.push(...this._findVideoFiles(fullPath, relativeTo));
95
+ } else if (entry.isFile()) {
96
+ const ext = path.extname(entry.name).toLowerCase();
97
+ if (config.videoExtensions.includes(ext)) {
98
+ const relativePath = path.relative(relativeTo, fullPath);
99
+ const parts = relativePath.split(path.sep);
100
+
101
+ // Determine module (parent folder or 'Root')
102
+ let moduleName = 'Root';
103
+ let modulePath = relativeTo;
104
+
105
+ if (parts.length > 1) {
106
+ moduleName = parts[0];
107
+ modulePath = path.join(relativeTo, parts[0]);
108
+ }
109
+
110
+ videos.push({
111
+ path: fullPath,
112
+ filename: entry.name,
113
+ title: this._extractTitle(entry.name),
114
+ moduleName,
115
+ modulePath,
116
+ sortOrder: this._extractSortOrder(entry.name),
117
+ });
118
+ }
119
+ }
120
+ }
121
+ } catch (err) {
122
+ // Ignore permission errors
123
+ }
124
+
125
+ return videos.sort((a, b) => a.sortOrder - b.sortOrder);
126
+ }
127
+
128
+ /**
129
+ * Extract a clean title from filename
130
+ * @param {string} filename - Video filename
131
+ * @returns {string} Clean title
132
+ * @private
133
+ */
134
+ _extractTitle(filename) {
135
+ // Remove extension
136
+ let title = path.basename(filename, path.extname(filename));
137
+
138
+ // Replace underscores with spaces (but keep hyphens and dots as they may be meaningful)
139
+ title = title.replace(/_/g, ' ');
140
+
141
+ return title.trim() || filename;
142
+ }
143
+
144
+ /**
145
+ * Extract sort order from filename (leading numbers)
146
+ * @param {string} filename - Video filename
147
+ * @returns {number} Sort order
148
+ * @private
149
+ */
150
+ _extractSortOrder(filename) {
151
+ const match = filename.match(/^(\d+)/);
152
+ return match ? parseInt(match[1], 10) : 999;
153
+ }
154
+
155
+ /**
156
+ * Get or create a module
157
+ * @param {string} modulePath - Full path to module folder
158
+ * @param {string} moduleName - Module name
159
+ * @returns {number|null} Module ID or null for root
160
+ * @private
161
+ */
162
+ _getOrCreateModule(modulePath, moduleName) {
163
+ if (moduleName === 'Root') return null;
164
+
165
+ const existing = this._db.get(
166
+ 'SELECT id FROM modules WHERE path = ?',
167
+ [modulePath]
168
+ );
169
+
170
+ if (existing) return existing.id;
171
+
172
+ const sortOrder = this._extractSortOrder(moduleName);
173
+ const result = this._db.run(
174
+ 'INSERT INTO modules (name, path, sort_order) VALUES (?, ?, ?)',
175
+ [moduleName, modulePath, sortOrder]
176
+ );
177
+
178
+ return result.lastInsertRowid;
179
+ }
180
+
181
+ /**
182
+ * Get all modules with their videos
183
+ * @param {string} sortBy - Sort order: 'name', 'name_desc', 'date', 'date_desc' (default: 'name')
184
+ * @returns {Array} Array of modules with videos
185
+ */
186
+ getAllModulesWithVideos(sortBy = 'name') {
187
+ const modules = this._db.all(
188
+ 'SELECT * FROM modules ORDER BY sort_order, name'
189
+ );
190
+
191
+ // Determine ORDER BY clause based on sortBy
192
+ let orderClause;
193
+ switch (sortBy) {
194
+ case 'name_desc':
195
+ orderClause = 'ORDER BY sort_order DESC, filename DESC';
196
+ break;
197
+ case 'date':
198
+ orderClause = 'ORDER BY created_at ASC';
199
+ break;
200
+ case 'date_desc':
201
+ orderClause = 'ORDER BY created_at DESC';
202
+ break;
203
+ case 'name':
204
+ default:
205
+ orderClause = 'ORDER BY sort_order, filename';
206
+ break;
207
+ }
208
+
209
+ // Get videos without a module (root level)
210
+ const rootVideos = this._db.all(
211
+ `SELECT * FROM videos WHERE module_id IS NULL ${orderClause}`
212
+ );
213
+
214
+ const result = [];
215
+
216
+ // Add root videos as a pseudo-module if any exist
217
+ if (rootVideos.length > 0) {
218
+ result.push({
219
+ id: null,
220
+ name: 'Videos',
221
+ videos: rootVideos,
222
+ });
223
+ }
224
+
225
+ // Add each module with its videos
226
+ for (const mod of modules) {
227
+ const videos = this._db.all(
228
+ `SELECT * FROM videos WHERE module_id = ? ${orderClause}`,
229
+ [mod.id]
230
+ );
231
+ result.push({
232
+ ...mod,
233
+ videos,
234
+ });
235
+ }
236
+
237
+ return result;
238
+ }
239
+
240
+ /**
241
+ * Get a video by ID
242
+ * @param {number} id - Video ID
243
+ * @returns {Object} Video object
244
+ * @throws {NotFoundError} If video not found
245
+ */
246
+ getVideoById(id) {
247
+ const video = this._db.get('SELECT * FROM videos WHERE id = ?', [id]);
248
+
249
+ if (!video) {
250
+ throw new NotFoundError(`Video with id ${id}`);
251
+ }
252
+
253
+ return video;
254
+ }
255
+
256
+ /**
257
+ * Get adjacent videos (previous and next)
258
+ * @param {number} id - Current video ID
259
+ * @returns {Object} Object with prev and next video IDs
260
+ */
261
+ getAdjacentVideos(id) {
262
+ const video = this.getVideoById(id);
263
+
264
+ // Get all videos in same module, ordered
265
+ const videos = this._db.all(
266
+ `SELECT id FROM videos
267
+ WHERE module_id ${video.module_id ? '= ?' : 'IS NULL'}
268
+ ORDER BY sort_order, filename`,
269
+ video.module_id ? [video.module_id] : []
270
+ );
271
+
272
+ const currentIndex = videos.findIndex(v => v.id === id);
273
+
274
+ return {
275
+ prev: currentIndex > 0 ? videos[currentIndex - 1].id : null,
276
+ next: currentIndex < videos.length - 1 ? videos[currentIndex + 1].id : null,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Search videos by title
282
+ * @param {string} query - Search query
283
+ * @returns {Array} Matching videos
284
+ */
285
+ searchVideos(query) {
286
+ const searchTerm = `%${query}%`;
287
+ return this._db.all(
288
+ `SELECT v.*, m.name as module_name
289
+ FROM videos v
290
+ LEFT JOIN modules m ON v.module_id = m.id
291
+ WHERE v.title LIKE ? OR v.filename LIKE ?
292
+ ORDER BY v.sort_order, v.filename`,
293
+ [searchTerm, searchTerm]
294
+ );
295
+ }
296
+
297
+ /**
298
+ * Get course statistics
299
+ * @returns {Object} Statistics object
300
+ */
301
+ getStats() {
302
+ const total = this._db.get('SELECT COUNT(*) as count FROM videos');
303
+ const completed = this._db.get(
304
+ "SELECT COUNT(*) as count FROM videos WHERE status = 'completed'"
305
+ );
306
+ const inProgress = this._db.get(
307
+ "SELECT COUNT(*) as count FROM videos WHERE status = 'in-progress'"
308
+ );
309
+
310
+ return {
311
+ total: total.count,
312
+ completed: completed.count,
313
+ inProgress: inProgress.count,
314
+ unwatched: total.count - completed.count - inProgress.count,
315
+ percentComplete: total.count > 0
316
+ ? Math.round((completed.count / total.count) * 100)
317
+ : 0,
318
+ };
319
+ }
320
+ }
321
+
322
+ module.exports = { VideoService };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Configuration Module
3
+ *
4
+ * Centralized configuration with environment variable support and defaults.
5
+ *
6
+ * @module utils/config
7
+ */
8
+
9
+ const path = require('path');
10
+
11
+ const config = {
12
+ // Server settings
13
+ port: parseInt(process.env.PORT, 10) || 3000,
14
+
15
+ // Database settings
16
+ dbFolder: '.coursewatcher',
17
+ dbFilename: 'database.sqlite',
18
+
19
+ // Video settings
20
+ videoExtensions: ['.mp4', '.webm', '.ogv', '.ogg'],
21
+ completionThreshold: 0.9, // 90% watched = completed
22
+
23
+ // Playback settings
24
+ defaultPlaybackSpeed: 1.0,
25
+ speedStep: 0.25,
26
+ seekShort: 5,
27
+ seekMedium: 10,
28
+ seekLong: 30,
29
+
30
+ // Progress auto-save interval (seconds)
31
+ progressSaveInterval: 5,
32
+ };
33
+
34
+ /**
35
+ * Get the database path for a given course directory
36
+ * @param {string} coursePath - Path to the course directory
37
+ * @returns {string} Full path to the database file
38
+ */
39
+ function getDbPath(coursePath) {
40
+ return path.join(coursePath, config.dbFolder, config.dbFilename);
41
+ }
42
+
43
+ /**
44
+ * Get the .coursewatcher folder path
45
+ * @param {string} coursePath - Path to the course directory
46
+ * @returns {string} Full path to the .coursewatcher folder
47
+ */
48
+ function getDataFolder(coursePath) {
49
+ return path.join(coursePath, config.dbFolder);
50
+ }
51
+
52
+ module.exports = {
53
+ config,
54
+ getDbPath,
55
+ getDataFolder,
56
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Custom Error Classes
3
+ *
4
+ * Application-specific error types for better error handling.
5
+ *
6
+ * @module utils/errors
7
+ */
8
+
9
+ /**
10
+ * Base error class for CourseWatcher
11
+ */
12
+ class CourseWatcherError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = 'CourseWatcherError';
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Error thrown when a resource is not found
21
+ */
22
+ class NotFoundError extends CourseWatcherError {
23
+ constructor(resource) {
24
+ super(`Not found: ${resource}`);
25
+ this.name = 'NotFoundError';
26
+ this.statusCode = 404;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Error thrown for validation failures
32
+ */
33
+ class ValidationError extends CourseWatcherError {
34
+ constructor(message) {
35
+ super(message);
36
+ this.name = 'ValidationError';
37
+ this.statusCode = 400;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Error thrown for database operations
43
+ */
44
+ class DatabaseError extends CourseWatcherError {
45
+ constructor(message) {
46
+ super(message);
47
+ this.name = 'DatabaseError';
48
+ this.statusCode = 500;
49
+ }
50
+ }
51
+
52
+ module.exports = {
53
+ CourseWatcherError,
54
+ NotFoundError,
55
+ ValidationError,
56
+ DatabaseError,
57
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Logger Utility
3
+ *
4
+ * Provides consistent console output with colored prefixes.
5
+ *
6
+ * @module utils/logger
7
+ */
8
+
9
+ const chalk = require('chalk');
10
+
11
+ /**
12
+ * Log an informational message
13
+ * @param {string} message - Message to log
14
+ */
15
+ function log(message) {
16
+ console.log(chalk.blue('ℹ'), message);
17
+ }
18
+
19
+ /**
20
+ * Log a success message
21
+ * @param {string} message - Message to log
22
+ */
23
+ function success(message) {
24
+ console.log(chalk.green('✓'), message);
25
+ }
26
+
27
+ /**
28
+ * Log an error message
29
+ * @param {string} message - Message to log
30
+ */
31
+ function error(message) {
32
+ console.error(chalk.red('✗'), message);
33
+ }
34
+
35
+ /**
36
+ * Log a warning message
37
+ * @param {string} message - Message to log
38
+ */
39
+ function warn(message) {
40
+ console.warn(chalk.yellow('⚠'), message);
41
+ }
42
+
43
+ module.exports = {
44
+ log,
45
+ success,
46
+ error,
47
+ warn,
48
+ };
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <%- include('../partials/head', { title }) %>
4
+ <body>
5
+ <%- include('../partials/header') %>
6
+
7
+ <main class="container">
8
+ <%- body %>
9
+ </main>
10
+
11
+ <%- include('../partials/footer') %>
12
+ </body>
13
+ </html>
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <%- include('../partials/head', { title: 'Error' }) %>
4
+
5
+ <body>
6
+ <%- include('../partials/header') %>
7
+
8
+ <main class="container">
9
+ <section class="error-section">
10
+ <div class="error-content">
11
+ <h1 class="error-code">
12
+ <%= statusCode %>
13
+ </h1>
14
+ <p class="error-message">
15
+ <%= message %>
16
+ </p>
17
+ <a href="/" class="btn btn-primary">← Back to Home</a>
18
+ </div>
19
+ </section>
20
+ </main>
21
+
22
+ <%- include('../partials/footer') %>
23
+ </body>
24
+
25
+ </html>
@@ -0,0 +1,101 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <%- include('../partials/head', { title }) %>
4
+
5
+ <body>
6
+ <%- include('../partials/header') %>
7
+
8
+ <main class="container">
9
+ <!-- Stats Section -->
10
+ <section class="stats-section">
11
+ <div class="stats-grid">
12
+ <div class="stat-card">
13
+ <span class="stat-value">
14
+ <%= stats.total %>
15
+ </span>
16
+ <span class="stat-label">Total Videos</span>
17
+ </div>
18
+ <div class="stat-card">
19
+ <span class="stat-value">
20
+ <%= stats.completed %>
21
+ </span>
22
+ <span class="stat-label">Completed</span>
23
+ </div>
24
+ <div class="stat-card">
25
+ <span class="stat-value">
26
+ <%= stats.inProgress %>
27
+ </span>
28
+ <span class="stat-label">In Progress</span>
29
+ </div>
30
+ <div class="stat-card stat-card-highlight">
31
+ <span class="stat-value">
32
+ <%= stats.percentComplete %>%
33
+ </span>
34
+ <span class="stat-label">Course Progress</span>
35
+ </div>
36
+ </div>
37
+ </section>
38
+
39
+ <!-- Modules Section -->
40
+ <section class="modules-section">
41
+ <div class="section-header">
42
+ <h2 class="section-title">Course Content</h2>
43
+
44
+ <!-- Sort Controls -->
45
+ <div class="sort-controls">
46
+ <span class="sort-label">Sort by:</span>
47
+ <div class="sort-buttons">
48
+ <a href="/?sort=name" class="sort-btn <%= currentSort === 'name' ? 'active' : '' %>">
49
+ Name ↑
50
+ </a>
51
+ <a href="/?sort=name_desc"
52
+ class="sort-btn <%= currentSort === 'name_desc' ? 'active' : '' %>">
53
+ Name ↓
54
+ </a>
55
+ <a href="/?sort=date" class="sort-btn <%= currentSort === 'date' ? 'active' : '' %>">
56
+ Date ↑
57
+ </a>
58
+ <a href="/?sort=date_desc"
59
+ class="sort-btn <%= currentSort === 'date_desc' ? 'active' : '' %>">
60
+ Date ↓
61
+ </a>
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <% if (modules.length===0) { %>
67
+ <div class="empty-state">
68
+ <p>No videos found in this directory.</p>
69
+ <p class="empty-hint">Make sure you're running CourseWatcher in a folder with video files.
70
+ </p>
71
+ </div>
72
+ <% } else { %>
73
+ <div class="modules-list">
74
+ <% modules.forEach((mod, index)=> { %>
75
+ <details class="module-item" <%=index===0 ? 'open' : '' %>>
76
+ <summary class="module-header">
77
+ <span class="module-icon">📁</span>
78
+ <span class="module-name">
79
+ <%= mod.name %>
80
+ </span>
81
+ <span class="module-count">
82
+ <%= mod.videos.length %> videos
83
+ </span>
84
+ <span class="module-toggle">▼</span>
85
+ </summary>
86
+ <div class="module-content">
87
+ <% mod.videos.forEach(video=> { %>
88
+ <%- include('../partials/video-card', { video }) %>
89
+ <% }) %>
90
+ </div>
91
+ </details>
92
+ <% }) %>
93
+ </div>
94
+ <% } %>
95
+ </section>
96
+ </main>
97
+
98
+ <%- include('../partials/footer') %>
99
+ </body>
100
+
101
+ </html>