coursewatcher 1.3.1 → 2.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.
Files changed (47) hide show
  1. package/README.md +26 -23
  2. package/dist/app/cli/main.js +36 -0
  3. package/dist/app/server/create-app.js +151 -0
  4. package/dist/app/server/start-server.js +72 -0
  5. package/dist/modules/catalog/catalog-mappers.js +49 -0
  6. package/dist/modules/catalog/catalog-repository.js +168 -0
  7. package/dist/modules/catalog/catalog-service.js +76 -0
  8. package/dist/modules/notes/notes-repository.js +25 -0
  9. package/dist/modules/notes/notes-service.js +28 -0
  10. package/dist/modules/playback/playback-repository.js +32 -0
  11. package/dist/modules/playback/playback-service.js +89 -0
  12. package/dist/platform/config/app-config.js +24 -0
  13. package/dist/platform/config/package-info.js +21 -0
  14. package/dist/platform/database/database-manager.js +101 -0
  15. package/dist/platform/errors/app-error.js +30 -0
  16. package/dist/platform/logging/logger.js +21 -0
  17. package/dist/shared/contracts/api.js +2 -0
  18. package/dist/web/assets/api-client-hFlLSS3K.js +1 -0
  19. package/dist/web/assets/catalog-route-pOIhR3yd.js +1 -0
  20. package/dist/web/assets/index-CwspbIw1.js +10 -0
  21. package/dist/web/assets/index-VjwsJnuQ.css +1 -0
  22. package/dist/web/assets/jsx-runtime-C2ZT__TU.js +4 -0
  23. package/dist/web/assets/playback-route-Bmy_Z7k7.js +2 -0
  24. package/dist/web/assets/search-route-CeGVOVPT.js +1 -0
  25. package/dist/web/index.html +14 -0
  26. package/package.json +75 -57
  27. package/public/css/styles.css +0 -1375
  28. package/public/js/player.js +0 -359
  29. package/src/cli.js +0 -45
  30. package/src/controllers/video-controller.js +0 -189
  31. package/src/models/database.js +0 -169
  32. package/src/server.js +0 -179
  33. package/src/services/notes-service.js +0 -97
  34. package/src/services/progress-service.js +0 -169
  35. package/src/services/video-service.js +0 -354
  36. package/src/utils/config.js +0 -57
  37. package/src/utils/errors.js +0 -57
  38. package/src/utils/logger.js +0 -48
  39. package/views/layouts/main.ejs +0 -13
  40. package/views/pages/error.ejs +0 -25
  41. package/views/pages/index.ejs +0 -101
  42. package/views/pages/player.ejs +0 -161
  43. package/views/pages/search.ejs +0 -63
  44. package/views/partials/footer.ejs +0 -3
  45. package/views/partials/head.ejs +0 -8
  46. package/views/partials/header.ejs +0 -14
  47. package/views/partials/video-card.ejs +0 -36
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PlaybackRepository = void 0;
4
+ class PlaybackRepository {
5
+ database;
6
+ constructor(database) {
7
+ this.database = database;
8
+ }
9
+ findVideo(videoId) {
10
+ return this.database.get('SELECT * FROM videos WHERE id = ?', [videoId]);
11
+ }
12
+ updateVideo(videoId, position, duration, status) {
13
+ this.database.run(`
14
+ UPDATE videos
15
+ SET position = ?,
16
+ duration = COALESCE(?, duration),
17
+ status = ?,
18
+ updated_at = CURRENT_TIMESTAMP
19
+ WHERE id = ?
20
+ `, [position, duration, status, videoId]);
21
+ }
22
+ updateVideoStatus(videoId, status, position) {
23
+ this.database.run(`
24
+ UPDATE videos
25
+ SET status = ?,
26
+ position = ?,
27
+ updated_at = CURRENT_TIMESTAMP
28
+ WHERE id = ?
29
+ `, [status, position, videoId]);
30
+ }
31
+ }
32
+ exports.PlaybackRepository = PlaybackRepository;
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PlaybackService = void 0;
4
+ const app_config_1 = require("../../platform/config/app-config");
5
+ const app_error_1 = require("../../platform/errors/app-error");
6
+ const catalog_mappers_1 = require("../catalog/catalog-mappers");
7
+ class PlaybackService {
8
+ playbackRepository;
9
+ catalogRepository;
10
+ constructor(playbackRepository, catalogRepository) {
11
+ this.playbackRepository = playbackRepository;
12
+ this.catalogRepository = catalogRepository;
13
+ }
14
+ getPlaybackStartPosition(video) {
15
+ const isShortVideo = video.duration > 0 && video.duration < app_config_1.appConfig.shortVideoResumeCutoffSeconds;
16
+ if (video.status === 'completed' || isShortVideo) {
17
+ return 0;
18
+ }
19
+ return video.status === 'in-progress' ? video.position : 0;
20
+ }
21
+ updatePosition(videoId, position, duration) {
22
+ if (typeof position !== 'number' || Number.isNaN(position) || position < 0) {
23
+ throw new app_error_1.ValidationError('Position must be a non-negative number');
24
+ }
25
+ const video = this.playbackRepository.findVideo(videoId);
26
+ if (!video) {
27
+ throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
28
+ }
29
+ const effectiveDuration = typeof duration === 'number' && !Number.isNaN(duration)
30
+ ? duration
31
+ : video.duration;
32
+ const isShortVideo = effectiveDuration > 0 && effectiveDuration < app_config_1.appConfig.shortVideoResumeCutoffSeconds;
33
+ const completionPosition = effectiveDuration * app_config_1.appConfig.completionThreshold;
34
+ let nextStatus = video.status;
35
+ if (effectiveDuration > 0) {
36
+ const watchPercent = position / effectiveDuration;
37
+ if (watchPercent >= app_config_1.appConfig.completionThreshold) {
38
+ nextStatus = 'completed';
39
+ }
40
+ else if (position > 0) {
41
+ nextStatus = 'in-progress';
42
+ }
43
+ }
44
+ else if (position > 0 && video.status === 'unwatched') {
45
+ nextStatus = 'in-progress';
46
+ }
47
+ const shouldPreserveCompletedProgress = video.status === 'completed' && effectiveDuration > 0 && position < completionPosition;
48
+ if (shouldPreserveCompletedProgress) {
49
+ nextStatus = 'completed';
50
+ }
51
+ let nextPosition = position;
52
+ if (shouldPreserveCompletedProgress) {
53
+ nextPosition = video.position;
54
+ }
55
+ else if (isShortVideo && nextStatus !== 'completed') {
56
+ nextPosition = 0;
57
+ }
58
+ this.playbackRepository.updateVideo(videoId, nextPosition, typeof duration === 'number' ? duration : null, nextStatus);
59
+ return {
60
+ success: true,
61
+ video: this.getVideoDetails(videoId),
62
+ };
63
+ }
64
+ updateStatus(videoId, status) {
65
+ const allowedStatuses = ['unwatched', 'in-progress', 'completed'];
66
+ if (!allowedStatuses.includes(status)) {
67
+ throw new app_error_1.ValidationError(`Invalid status. Must be one of: ${allowedStatuses.join(', ')}`);
68
+ }
69
+ const video = this.playbackRepository.findVideo(videoId);
70
+ if (!video) {
71
+ throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
72
+ }
73
+ const nextPosition = status === 'unwatched' ? 0 : video.position;
74
+ this.playbackRepository.updateVideoStatus(videoId, status, nextPosition);
75
+ return {
76
+ success: true,
77
+ video: this.getVideoDetails(videoId),
78
+ };
79
+ }
80
+ getVideoDetails(videoId) {
81
+ const video = this.catalogRepository.findVideoById(videoId);
82
+ if (!video) {
83
+ throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
84
+ }
85
+ const moduleName = this.catalogRepository.findModuleName(video.module_id);
86
+ return (0, catalog_mappers_1.toVideoDetails)(video, moduleName);
87
+ }
88
+ }
89
+ exports.PlaybackService = PlaybackService;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.appConfig = void 0;
7
+ exports.getDataFolder = getDataFolder;
8
+ exports.getDbPath = getDbPath;
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ exports.appConfig = {
11
+ dbFolder: '.coursewatcher',
12
+ dbFilename: 'database.sqlite',
13
+ videoExtensions: ['.mp4', '.webm', '.ogv', '.ogg'],
14
+ completionThreshold: 0.9,
15
+ shortVideoResumeCutoffSeconds: 300,
16
+ defaultPort: 3000,
17
+ isDevelopment: process.env.NODE_ENV === 'development',
18
+ };
19
+ function getDataFolder(coursePath) {
20
+ return node_path_1.default.join(coursePath, exports.appConfig.dbFolder);
21
+ }
22
+ function getDbPath(coursePath) {
23
+ return node_path_1.default.join(getDataFolder(coursePath), exports.appConfig.dbFilename);
24
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getPackageInfo = getPackageInfo;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ let cachedPackageInfo = null;
10
+ function getPackageInfo() {
11
+ if (cachedPackageInfo) {
12
+ return cachedPackageInfo;
13
+ }
14
+ const packageJsonPath = node_path_1.default.resolve(__dirname, '../../../package.json');
15
+ const packageJson = JSON.parse(node_fs_1.default.readFileSync(packageJsonPath, 'utf8'));
16
+ cachedPackageInfo = {
17
+ description: packageJson.description,
18
+ version: packageJson.version,
19
+ };
20
+ return cachedPackageInfo;
21
+ }
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DatabaseManager = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
9
+ const app_config_1 = require("../config/app-config");
10
+ const app_error_1 = require("../errors/app-error");
11
+ class DatabaseManager {
12
+ coursePath;
13
+ db = null;
14
+ constructor(coursePath) {
15
+ this.coursePath = coursePath;
16
+ }
17
+ initialize() {
18
+ try {
19
+ const dataFolder = (0, app_config_1.getDataFolder)(this.coursePath);
20
+ if (!node_fs_1.default.existsSync(dataFolder)) {
21
+ node_fs_1.default.mkdirSync(dataFolder, { recursive: true });
22
+ }
23
+ this.db = new better_sqlite3_1.default((0, app_config_1.getDbPath)(this.coursePath));
24
+ this.db.pragma('foreign_keys = ON');
25
+ this.createSchema();
26
+ return this;
27
+ }
28
+ catch (caughtError) {
29
+ const message = caughtError instanceof Error ? caughtError.message : 'Unknown error';
30
+ throw new app_error_1.DatabaseError(`Failed to initialize database: ${message}`);
31
+ }
32
+ }
33
+ getCoursePath() {
34
+ return this.coursePath;
35
+ }
36
+ get(sql, params = []) {
37
+ return this.requireDb().prepare(sql).get(...params);
38
+ }
39
+ all(sql, params = []) {
40
+ return this.requireDb().prepare(sql).all(...params);
41
+ }
42
+ run(sql, params = []) {
43
+ return this.requireDb().prepare(sql).run(...params);
44
+ }
45
+ transaction(callback) {
46
+ return this.requireDb().transaction(callback)();
47
+ }
48
+ close() {
49
+ if (this.db) {
50
+ this.db.close();
51
+ this.db = null;
52
+ }
53
+ }
54
+ requireDb() {
55
+ if (!this.db) {
56
+ throw new app_error_1.DatabaseError('Database is not initialized');
57
+ }
58
+ return this.db;
59
+ }
60
+ createSchema() {
61
+ const db = this.requireDb();
62
+ db.exec(`
63
+ CREATE TABLE IF NOT EXISTS modules (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ name TEXT NOT NULL,
66
+ path TEXT NOT NULL UNIQUE,
67
+ sort_order INTEGER DEFAULT 0,
68
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS videos (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ path TEXT NOT NULL UNIQUE,
74
+ filename TEXT NOT NULL,
75
+ title TEXT NOT NULL,
76
+ duration REAL DEFAULT 0,
77
+ position REAL DEFAULT 0,
78
+ status TEXT CHECK(status IN ('unwatched', 'in-progress', 'completed')) DEFAULT 'unwatched',
79
+ module_id INTEGER,
80
+ sort_order INTEGER DEFAULT 0,
81
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
82
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
83
+ FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE SET NULL
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS notes (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ video_id INTEGER NOT NULL UNIQUE,
89
+ content TEXT DEFAULT '',
90
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
91
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
92
+ FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
93
+ );
94
+
95
+ CREATE INDEX IF NOT EXISTS idx_videos_module ON videos(module_id);
96
+ CREATE INDEX IF NOT EXISTS idx_videos_status ON videos(status);
97
+ CREATE INDEX IF NOT EXISTS idx_videos_path ON videos(path);
98
+ `);
99
+ }
100
+ }
101
+ exports.DatabaseManager = DatabaseManager;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DatabaseError = exports.ValidationError = exports.NotFoundError = exports.AppError = void 0;
4
+ class AppError extends Error {
5
+ statusCode;
6
+ constructor(message, statusCode = 500) {
7
+ super(message);
8
+ this.name = new.target.name;
9
+ this.statusCode = statusCode;
10
+ }
11
+ }
12
+ exports.AppError = AppError;
13
+ class NotFoundError extends AppError {
14
+ constructor(resourceName) {
15
+ super(`${resourceName} not found`, 404);
16
+ }
17
+ }
18
+ exports.NotFoundError = NotFoundError;
19
+ class ValidationError extends AppError {
20
+ constructor(message) {
21
+ super(message, 400);
22
+ }
23
+ }
24
+ exports.ValidationError = ValidationError;
25
+ class DatabaseError extends AppError {
26
+ constructor(message) {
27
+ super(message, 500);
28
+ }
29
+ }
30
+ exports.DatabaseError = DatabaseError;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.log = log;
7
+ exports.success = success;
8
+ exports.error = error;
9
+ const chalk_1 = __importDefault(require("chalk"));
10
+ function print(label, color, message) {
11
+ console.log(`${color(label)} ${message}`);
12
+ }
13
+ function log(message) {
14
+ print('[CourseWatcher]', chalk_1.default.blue, message);
15
+ }
16
+ function success(message) {
17
+ print('[CourseWatcher]', chalk_1.default.green, message);
18
+ }
19
+ function error(message) {
20
+ print('[CourseWatcher]', chalk_1.default.red, message);
21
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
1
+ var e=class extends Error{constructor(e,t){super(e),this.status=t,this.name=`HttpError`}};async function t(t,n){let r=await fetch(t,n);if(!r.ok)throw new e((await r.json().catch(()=>null))?.message??`Request failed`,r.status);return r.json()}function n(e){return t(`/api/catalog${e?`?sort=${encodeURIComponent(e)}`:``}`)}function r(e){return t(`/api/videos/${e}`)}function i(e){return t(`/api/search${e?`?q=${encodeURIComponent(e)}`:``}`)}function a(e,n,r){return t(`/api/videos/${e}/progress`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({position:n,duration:r})})}function o(e,n){return t(`/api/videos/${e}/status`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({status:n})})}function s(e,n){return t(`/api/videos/${e}/notes`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({content:n})})}function c(e,t,n){return navigator.sendBeacon(`/api/videos/${e}/progress`,new Blob([JSON.stringify({position:t,duration:n})],{type:`application/json`}))}export{s as a,i,n,a as o,r,o as s,c as t};
@@ -0,0 +1 @@
1
+ import{f as e,l as t,m as n,n as r,s as i,t as a}from"./jsx-runtime-C2ZT__TU.js";import{n as o}from"./api-client-hFlLSS3K.js";var s=n(e()),c=a(),l=`coursewatcher.catalog.view-mode`;async function u({request:e}){return o(new URL(e.url).searchParams.get(`sort`)??void 0)}function d(){let{currentSort:e,modules:n,stats:a}=i(),o=t(),[u,d]=(0,s.useState)(()=>typeof window>`u`?`list`:window.localStorage.getItem(l)===`grid`?`grid`:`list`);(0,s.useEffect)(()=>{window.localStorage.setItem(l,u)},[u]);function f(e){(0,s.startTransition)(()=>{o(e===`name`?`/`:`/?sort=${e}`)})}return(0,c.jsxs)(`div`,{className:`stack`,children:[(0,c.jsxs)(`section`,{className:`stats-grid`,children:[(0,c.jsxs)(`article`,{className:`stat-card`,children:[(0,c.jsx)(`span`,{className:`stat-value`,children:a.total}),(0,c.jsx)(`span`,{className:`stat-label`,children:`Total Videos`})]}),(0,c.jsxs)(`article`,{className:`stat-card`,children:[(0,c.jsx)(`span`,{className:`stat-value`,children:a.completed}),(0,c.jsx)(`span`,{className:`stat-label`,children:`Completed`})]}),(0,c.jsxs)(`article`,{className:`stat-card`,children:[(0,c.jsx)(`span`,{className:`stat-value`,children:a.inProgress}),(0,c.jsx)(`span`,{className:`stat-label`,children:`In Progress`})]}),(0,c.jsxs)(`article`,{className:`stat-card accent`,children:[(0,c.jsxs)(`span`,{className:`stat-value`,children:[a.percentComplete,`%`]}),(0,c.jsx)(`span`,{className:`stat-label`,children:`Course Progress`})]})]}),(0,c.jsxs)(`section`,{className:`panel`,children:[(0,c.jsxs)(`div`,{className:`section-header`,children:[(0,c.jsxs)(`div`,{children:[(0,c.jsx)(`h1`,{children:`Course Content`}),(0,c.jsx)(`p`,{children:`Organized by folder structure with progress-aware list and card views.`})]}),(0,c.jsxs)(`div`,{className:`controls-cluster`,children:[(0,c.jsx)(`div`,{className:`pill-group`,children:[[`name`,`Name ↑`],[`name_desc`,`Name ↓`],[`date`,`Date ↑`],[`date_desc`,`Date ↓`]].map(([t,n])=>(0,c.jsx)(`button`,{className:e===t?`pill active`:`pill`,onClick:()=>f(t),type:`button`,children:n},t))}),(0,c.jsxs)(`div`,{className:`pill-group`,children:[(0,c.jsx)(`button`,{className:u===`list`?`pill active`:`pill`,onClick:()=>d(`list`),type:`button`,children:`List`}),(0,c.jsx)(`button`,{className:u===`grid`?`pill active`:`pill`,onClick:()=>d(`grid`),type:`button`,children:`Tiles`})]})]})]}),n.length===0?(0,c.jsxs)(`div`,{className:`empty-state`,children:[(0,c.jsx)(`h2`,{children:`No videos found`}),(0,c.jsx)(`p`,{children:`Run CourseWatcher inside a folder that contains course videos.`})]}):(0,c.jsx)(`div`,{className:`module-list`,children:n.map((e,t)=>(0,c.jsxs)(`details`,{className:`module-card`,open:t===0,children:[(0,c.jsxs)(`summary`,{children:[(0,c.jsxs)(`div`,{children:[(0,c.jsx)(`span`,{className:`eyebrow`,children:`Module`}),(0,c.jsx)(`h2`,{children:e.name})]}),(0,c.jsxs)(`span`,{className:`meta-chip`,children:[e.videos.length,` videos`]})]}),(0,c.jsx)(`div`,{className:u===`list`?`video-list`:`video-grid`,children:e.videos.map(e=>{let t=e.duration>0?Math.round(e.position/e.duration*100):0;return(0,c.jsxs)(r,{className:u===`list`?`video-row`:`video-card`,to:`/video/${e.id}`,children:[(0,c.jsxs)(`div`,{className:`video-card-top`,children:[(0,c.jsx)(`span`,{className:`status-badge status-${e.status}`,children:e.status}),(0,c.jsxs)(`span`,{className:`meta-chip`,children:[t,`% watched`]})]}),(0,c.jsxs)(`div`,{className:`video-copy`,children:[(0,c.jsx)(`h3`,{children:e.title}),(0,c.jsx)(`p`,{children:e.filename})]}),(0,c.jsx)(`div`,{className:`progress-track`,children:(0,c.jsx)(`span`,{style:{width:`${t}%`}})})]},e.id)})})]},`${e.name}-${e.id??`root`}`))})]})]})}export{d as CatalogRoute,u as catalogLoader};