coursewatcher 1.3.0 → 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.
- package/README.md +26 -23
- package/dist/app/cli/main.js +36 -0
- package/dist/app/server/create-app.js +151 -0
- package/dist/app/server/start-server.js +72 -0
- package/dist/modules/catalog/catalog-mappers.js +49 -0
- package/dist/modules/catalog/catalog-repository.js +168 -0
- package/dist/modules/catalog/catalog-service.js +76 -0
- package/dist/modules/notes/notes-repository.js +25 -0
- package/dist/modules/notes/notes-service.js +28 -0
- package/dist/modules/playback/playback-repository.js +32 -0
- package/dist/modules/playback/playback-service.js +89 -0
- package/dist/platform/config/app-config.js +24 -0
- package/dist/platform/config/package-info.js +21 -0
- package/dist/platform/database/database-manager.js +101 -0
- package/dist/platform/errors/app-error.js +30 -0
- package/dist/platform/logging/logger.js +21 -0
- package/dist/shared/contracts/api.js +2 -0
- package/dist/web/assets/api-client-hFlLSS3K.js +1 -0
- package/dist/web/assets/catalog-route-pOIhR3yd.js +1 -0
- package/dist/web/assets/index-CwspbIw1.js +10 -0
- package/dist/web/assets/index-VjwsJnuQ.css +1 -0
- package/dist/web/assets/jsx-runtime-C2ZT__TU.js +4 -0
- package/dist/web/assets/playback-route-Bmy_Z7k7.js +2 -0
- package/dist/web/assets/search-route-CeGVOVPT.js +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +75 -57
- package/public/css/styles.css +0 -1375
- package/public/js/player.js +0 -359
- package/src/cli.js +0 -45
- package/src/controllers/video-controller.js +0 -175
- package/src/models/database.js +0 -169
- package/src/server.js +0 -179
- package/src/services/notes-service.js +0 -97
- package/src/services/progress-service.js +0 -148
- package/src/services/video-service.js +0 -354
- package/src/utils/config.js +0 -56
- package/src/utils/errors.js +0 -57
- package/src/utils/logger.js +0 -48
- package/views/layouts/main.ejs +0 -13
- package/views/pages/error.ejs +0 -25
- package/views/pages/index.ejs +0 -101
- package/views/pages/player.ejs +0 -161
- package/views/pages/search.ejs +0 -63
- package/views/partials/footer.ejs +0 -3
- package/views/partials/head.ejs +0 -8
- package/views/partials/header.ejs +0 -14
- package/views/partials/video-card.ejs +0 -36
package/README.md
CHANGED
|
@@ -5,20 +5,21 @@
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
**A CLI
|
|
8
|
+
**A TypeScript CLI and React web app for tracking progress in downloaded video courses.**
|
|
9
9
|
|
|
10
10
|
## Quick Start
|
|
11
11
|
|
|
12
|
-
```bash
|
|
13
|
-
# Navigate to your course folder
|
|
14
|
-
cd /path/to/my-course
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
```bash
|
|
13
|
+
# Navigate to your course folder
|
|
14
|
+
cd /path/to/my-course
|
|
15
|
+
|
|
16
|
+
# Build once, then run CourseWatcher
|
|
17
|
+
npm run build
|
|
18
|
+
node dist/app/cli/main.js
|
|
19
|
+
|
|
20
|
+
# Or with options
|
|
21
|
+
node dist/app/cli/main.js --port 8080 --no-browser
|
|
22
|
+
```
|
|
22
23
|
|
|
23
24
|
## CLI Options
|
|
24
25
|
|
|
@@ -59,18 +60,20 @@ npm install -g coursewatcher
|
|
|
59
60
|
| `M` | Mute/Unmute |
|
|
60
61
|
| `F` | Fullscreen |
|
|
61
62
|
|
|
62
|
-
## Development
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
# Install dependencies
|
|
66
|
-
npm install
|
|
67
|
-
|
|
68
|
-
# Run
|
|
69
|
-
npm run dev
|
|
70
|
-
|
|
71
|
-
#
|
|
72
|
-
npm
|
|
73
|
-
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Install dependencies
|
|
67
|
+
npm install
|
|
68
|
+
|
|
69
|
+
# Run the TypeScript CLI + Express server with Vite middleware
|
|
70
|
+
npm run dev
|
|
71
|
+
|
|
72
|
+
# Typecheck, test, and build
|
|
73
|
+
npm run typecheck
|
|
74
|
+
npm test
|
|
75
|
+
npm run build
|
|
76
|
+
```
|
|
74
77
|
|
|
75
78
|
## License
|
|
76
79
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const package_info_1 = require("../../platform/config/package-info");
|
|
6
|
+
const logger_1 = require("../../platform/logging/logger");
|
|
7
|
+
const start_server_1 = require("../server/start-server");
|
|
8
|
+
const packageInfo = (0, package_info_1.getPackageInfo)();
|
|
9
|
+
commander_1.program
|
|
10
|
+
.name('coursewatcher')
|
|
11
|
+
.description(packageInfo.description)
|
|
12
|
+
.version(packageInfo.version)
|
|
13
|
+
.argument('[path]', 'path to course directory', '.')
|
|
14
|
+
.option('-p, --port <number>', 'server port')
|
|
15
|
+
.option('--no-browser', 'do not open browser automatically')
|
|
16
|
+
.action(async (coursePath, options) => {
|
|
17
|
+
try {
|
|
18
|
+
const resolvedCoursePath = (0, start_server_1.resolveCoursePath)(coursePath);
|
|
19
|
+
const isPortSpecified = typeof options.port === 'string';
|
|
20
|
+
const portOption = options.port;
|
|
21
|
+
const port = isPortSpecified && portOption ? Number.parseInt(portOption, 10) : (0, start_server_1.getDefaultPort)();
|
|
22
|
+
(0, logger_1.log)(`Starting CourseWatcher in: ${resolvedCoursePath}`);
|
|
23
|
+
await (0, start_server_1.startServer)({
|
|
24
|
+
coursePath: resolvedCoursePath,
|
|
25
|
+
port,
|
|
26
|
+
allowFallback: !isPortSpecified,
|
|
27
|
+
openBrowser: options.browser,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (caughtError) {
|
|
31
|
+
const message = caughtError instanceof Error ? caughtError.message : 'Unknown error';
|
|
32
|
+
(0, logger_1.error)(`Failed to start CourseWatcher: ${message}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
void commander_1.program.parseAsync();
|
|
@@ -0,0 +1,151 @@
|
|
|
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.createCourseWatcherApp = createCourseWatcherApp;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const express_1 = __importDefault(require("express"));
|
|
11
|
+
const catalog_repository_1 = require("../../modules/catalog/catalog-repository");
|
|
12
|
+
const catalog_service_1 = require("../../modules/catalog/catalog-service");
|
|
13
|
+
const notes_repository_1 = require("../../modules/notes/notes-repository");
|
|
14
|
+
const notes_service_1 = require("../../modules/notes/notes-service");
|
|
15
|
+
const playback_repository_1 = require("../../modules/playback/playback-repository");
|
|
16
|
+
const playback_service_1 = require("../../modules/playback/playback-service");
|
|
17
|
+
const app_config_1 = require("../../platform/config/app-config");
|
|
18
|
+
const app_error_1 = require("../../platform/errors/app-error");
|
|
19
|
+
const logger_1 = require("../../platform/logging/logger");
|
|
20
|
+
async function createCourseWatcherApp(database) {
|
|
21
|
+
const catalogRepository = new catalog_repository_1.CatalogRepository(database);
|
|
22
|
+
const notesRepository = new notes_repository_1.NotesRepository(database);
|
|
23
|
+
const playbackRepository = new playback_repository_1.PlaybackRepository(database);
|
|
24
|
+
const notesService = new notes_service_1.NotesService(notesRepository);
|
|
25
|
+
const playbackService = new playback_service_1.PlaybackService(playbackRepository, catalogRepository);
|
|
26
|
+
const catalogService = new catalog_service_1.CatalogService(catalogRepository, playbackService, notesService);
|
|
27
|
+
const app = (0, express_1.default)();
|
|
28
|
+
app.use(express_1.default.json());
|
|
29
|
+
app.get('/api/catalog', (request, response) => {
|
|
30
|
+
response.json(catalogService.getCatalog(getQueryValue(request.query.sort)));
|
|
31
|
+
});
|
|
32
|
+
app.get('/api/stats', (_request, response) => {
|
|
33
|
+
response.json(catalogService.getStats());
|
|
34
|
+
});
|
|
35
|
+
app.get('/api/search', (request, response) => {
|
|
36
|
+
response.json(catalogService.searchVideos(getQueryValue(request.query.q) ?? ''));
|
|
37
|
+
});
|
|
38
|
+
app.get('/api/videos/:id', (request, response) => {
|
|
39
|
+
response.json(catalogService.getPlayerPayload(getNumericRouteParam(request.params.id)));
|
|
40
|
+
});
|
|
41
|
+
app.get('/api/videos/:id/stream', (request, response) => {
|
|
42
|
+
response.sendFile(catalogService.getVideoPath(getNumericRouteParam(request.params.id)));
|
|
43
|
+
});
|
|
44
|
+
app.post('/api/videos/:id/progress', (request, response) => {
|
|
45
|
+
const videoId = getNumericRouteParam(request.params.id);
|
|
46
|
+
const { duration, position } = request.body;
|
|
47
|
+
response.json(playbackService.updatePosition(videoId, position ?? 0, duration ?? null));
|
|
48
|
+
});
|
|
49
|
+
app.post('/api/videos/:id/status', (request, response) => {
|
|
50
|
+
const videoId = getNumericRouteParam(request.params.id);
|
|
51
|
+
const { status } = request.body;
|
|
52
|
+
response.json(playbackService.updateStatus(videoId, status ?? 'unwatched'));
|
|
53
|
+
});
|
|
54
|
+
app.get('/api/videos/:id/notes', (request, response) => {
|
|
55
|
+
response.json(notesService.getNotes(getNumericRouteParam(request.params.id)));
|
|
56
|
+
});
|
|
57
|
+
app.post('/api/videos/:id/notes', (request, response) => {
|
|
58
|
+
const videoId = getNumericRouteParam(request.params.id);
|
|
59
|
+
const { content } = request.body;
|
|
60
|
+
response.json({
|
|
61
|
+
success: true,
|
|
62
|
+
notes: notesService.saveNotes(videoId, content ?? ''),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
await registerWebApplication(app);
|
|
66
|
+
app.use((caughtError, request, response, _next) => {
|
|
67
|
+
const appError = normalizeError(caughtError);
|
|
68
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
69
|
+
(0, logger_1.error)(`${appError.name}: ${appError.message}`);
|
|
70
|
+
if (appError.stack) {
|
|
71
|
+
console.error(appError.stack);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (request.path.startsWith('/api/')) {
|
|
75
|
+
response.status(appError.statusCode).json({
|
|
76
|
+
error: true,
|
|
77
|
+
message: appError.message,
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
response.status(appError.statusCode).send(appError.message);
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
app,
|
|
85
|
+
services: {
|
|
86
|
+
catalogService,
|
|
87
|
+
notesService,
|
|
88
|
+
playbackService,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function getNumericRouteParam(value) {
|
|
93
|
+
const parsed = Number.parseInt(value, 10);
|
|
94
|
+
if (Number.isNaN(parsed)) {
|
|
95
|
+
throw new app_error_1.AppError('Invalid route parameter', 400);
|
|
96
|
+
}
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
function getQueryValue(value) {
|
|
100
|
+
return typeof value === 'string' ? value : undefined;
|
|
101
|
+
}
|
|
102
|
+
function normalizeError(caughtError) {
|
|
103
|
+
if (caughtError instanceof app_error_1.AppError) {
|
|
104
|
+
return caughtError;
|
|
105
|
+
}
|
|
106
|
+
if (caughtError instanceof Error) {
|
|
107
|
+
return new app_error_1.AppError(caughtError.message, 500);
|
|
108
|
+
}
|
|
109
|
+
return new app_error_1.AppError('Internal Server Error', 500);
|
|
110
|
+
}
|
|
111
|
+
async function registerWebApplication(app) {
|
|
112
|
+
if (app_config_1.appConfig.isDevelopment) {
|
|
113
|
+
const { createServer } = await import('vite');
|
|
114
|
+
const vite = await createServer({
|
|
115
|
+
configFile: node_path_1.default.resolve(process.cwd(), 'vite.config.ts'),
|
|
116
|
+
server: { middlewareMode: true },
|
|
117
|
+
appType: 'custom',
|
|
118
|
+
});
|
|
119
|
+
app.use(vite.middlewares);
|
|
120
|
+
app.use(async (request, response, next) => {
|
|
121
|
+
if (request.path.startsWith('/api/')) {
|
|
122
|
+
next();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const templatePath = node_path_1.default.resolve(process.cwd(), 'web/index.html');
|
|
127
|
+
const template = await promises_1.default.readFile(templatePath, 'utf8');
|
|
128
|
+
const html = await vite.transformIndexHtml(request.originalUrl, template);
|
|
129
|
+
response.status(200).set({ 'Content-Type': 'text/html' }).end(html);
|
|
130
|
+
}
|
|
131
|
+
catch (caughtError) {
|
|
132
|
+
next(caughtError);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const webDistPath = node_path_1.default.resolve(process.cwd(), 'dist/web');
|
|
138
|
+
app.use(express_1.default.static(webDistPath));
|
|
139
|
+
app.use((request, response, next) => {
|
|
140
|
+
if (request.path.startsWith('/api/')) {
|
|
141
|
+
next();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const indexPath = node_path_1.default.join(webDistPath, 'index.html');
|
|
145
|
+
if (!node_fs_1.default.existsSync(indexPath)) {
|
|
146
|
+
response.status(200).type('html').send('<!doctype html><html><body><div id="root"></div></body></html>');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
response.sendFile(indexPath);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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.startServer = startServer;
|
|
7
|
+
exports.resolveCoursePath = resolveCoursePath;
|
|
8
|
+
exports.getDefaultPort = getDefaultPort;
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const open_1 = __importDefault(require("open"));
|
|
11
|
+
const create_app_1 = require("./create-app");
|
|
12
|
+
const app_config_1 = require("../../platform/config/app-config");
|
|
13
|
+
const database_manager_1 = require("../../platform/database/database-manager");
|
|
14
|
+
const logger_1 = require("../../platform/logging/logger");
|
|
15
|
+
async function startServer(options) {
|
|
16
|
+
const database = new database_manager_1.DatabaseManager(options.coursePath).initialize();
|
|
17
|
+
const { app, services } = await (0, create_app_1.createCourseWatcherApp)(database);
|
|
18
|
+
(0, logger_1.log)('Scanning for video files...');
|
|
19
|
+
const scanResult = services.catalogService.scanVideos();
|
|
20
|
+
(0, logger_1.success)(`Found ${scanResult.total} videos (${scanResult.added} new, ${scanResult.existing} existing)`);
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const sockets = new Set();
|
|
23
|
+
const start = (port) => {
|
|
24
|
+
const server = app.listen(port, () => {
|
|
25
|
+
const url = `http://localhost:${port}`;
|
|
26
|
+
(0, logger_1.success)(`Server running at ${url}`);
|
|
27
|
+
if (options.openBrowser) {
|
|
28
|
+
void (0, open_1.default)(url).catch(() => undefined);
|
|
29
|
+
}
|
|
30
|
+
server.on('connection', (socket) => {
|
|
31
|
+
sockets.add(socket);
|
|
32
|
+
socket.on('close', () => sockets.delete(socket));
|
|
33
|
+
});
|
|
34
|
+
const shutdown = (signal) => {
|
|
35
|
+
(0, logger_1.log)(`Shutting down... (${signal})`);
|
|
36
|
+
for (const socket of sockets) {
|
|
37
|
+
socket.destroy();
|
|
38
|
+
}
|
|
39
|
+
database.close();
|
|
40
|
+
server.close(() => {
|
|
41
|
+
(0, logger_1.success)('Server closed');
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
46
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
47
|
+
resolve({
|
|
48
|
+
server,
|
|
49
|
+
database,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
server.on('error', (caughtError) => {
|
|
53
|
+
if (caughtError.code === 'EADDRINUSE' && options.allowFallback) {
|
|
54
|
+
(0, logger_1.log)(`Port ${port} is busy, trying ${port + 1}...`);
|
|
55
|
+
start(port + 1);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (caughtError.code === 'EADDRINUSE') {
|
|
59
|
+
(0, logger_1.error)(`Port ${port} is already in use`);
|
|
60
|
+
}
|
|
61
|
+
reject(caughtError);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
start(options.port);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function resolveCoursePath(inputPath) {
|
|
68
|
+
return node_path_1.default.resolve(inputPath);
|
|
69
|
+
}
|
|
70
|
+
function getDefaultPort() {
|
|
71
|
+
return app_config_1.appConfig.defaultPort;
|
|
72
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeSort = normalizeSort;
|
|
4
|
+
exports.toVideoSummary = toVideoSummary;
|
|
5
|
+
exports.toVideoDetails = toVideoDetails;
|
|
6
|
+
exports.toCatalogModule = toCatalogModule;
|
|
7
|
+
exports.toCourseStats = toCourseStats;
|
|
8
|
+
exports.toSearchResult = toSearchResult;
|
|
9
|
+
function normalizeSort(sortBy) {
|
|
10
|
+
const allowedSorts = ['name', 'name_desc', 'date', 'date_desc'];
|
|
11
|
+
return allowedSorts.includes(sortBy) ? sortBy : 'name';
|
|
12
|
+
}
|
|
13
|
+
function toVideoSummary(video, moduleName = null) {
|
|
14
|
+
return {
|
|
15
|
+
id: video.id,
|
|
16
|
+
title: video.title,
|
|
17
|
+
filename: video.filename,
|
|
18
|
+
duration: video.duration,
|
|
19
|
+
position: video.position,
|
|
20
|
+
status: video.status,
|
|
21
|
+
moduleId: video.module_id,
|
|
22
|
+
moduleName,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function toVideoDetails(video, moduleName = null) {
|
|
26
|
+
return {
|
|
27
|
+
...toVideoSummary(video, moduleName),
|
|
28
|
+
path: video.path,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function toCatalogModule(moduleRow, videos) {
|
|
32
|
+
return {
|
|
33
|
+
id: moduleRow?.id ?? null,
|
|
34
|
+
name: moduleRow?.name ?? 'Videos',
|
|
35
|
+
videos,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function toCourseStats(total, completed, inProgress) {
|
|
39
|
+
return {
|
|
40
|
+
total,
|
|
41
|
+
completed,
|
|
42
|
+
inProgress,
|
|
43
|
+
unwatched: total - completed - inProgress,
|
|
44
|
+
percentComplete: total > 0 ? Math.round((completed / total) * 100) : 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function toSearchResult(video, moduleName) {
|
|
48
|
+
return toVideoSummary(video, moduleName);
|
|
49
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
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.CatalogRepository = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const app_config_1 = require("../../platform/config/app-config");
|
|
10
|
+
class CatalogRepository {
|
|
11
|
+
database;
|
|
12
|
+
constructor(database) {
|
|
13
|
+
this.database = database;
|
|
14
|
+
}
|
|
15
|
+
scanVideos() {
|
|
16
|
+
const coursePath = this.database.getCoursePath();
|
|
17
|
+
const videos = this.findVideoFiles(coursePath);
|
|
18
|
+
let added = 0;
|
|
19
|
+
let existing = 0;
|
|
20
|
+
this.database.transaction(() => {
|
|
21
|
+
for (const video of videos) {
|
|
22
|
+
const current = this.database.get('SELECT id FROM videos WHERE path = ?', [video.path]);
|
|
23
|
+
if (current) {
|
|
24
|
+
this.database.run('UPDATE videos SET title = ? WHERE id = ?', [video.title, current.id]);
|
|
25
|
+
existing += 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const moduleId = this.getOrCreateModule(video.modulePath, video.moduleName);
|
|
29
|
+
this.database.run(`
|
|
30
|
+
INSERT INTO videos (path, filename, title, module_id, sort_order)
|
|
31
|
+
VALUES (?, ?, ?, ?, ?)
|
|
32
|
+
`, [video.path, video.filename, video.title, moduleId, video.sortOrder]);
|
|
33
|
+
added += 1;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
total: videos.length,
|
|
38
|
+
added,
|
|
39
|
+
existing,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
findVideoById(videoId) {
|
|
43
|
+
return this.database.get('SELECT * FROM videos WHERE id = ?', [videoId]);
|
|
44
|
+
}
|
|
45
|
+
findModuleName(moduleId) {
|
|
46
|
+
if (moduleId === null) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return this.database.get('SELECT name FROM modules WHERE id = ?', [moduleId])?.name ?? null;
|
|
50
|
+
}
|
|
51
|
+
listModules() {
|
|
52
|
+
return this.database.all('SELECT * FROM modules ORDER BY sort_order, name');
|
|
53
|
+
}
|
|
54
|
+
listVideosForModule(moduleId, sortBy) {
|
|
55
|
+
const orderBy = this.getVideoOrderBy(sortBy);
|
|
56
|
+
if (moduleId === null) {
|
|
57
|
+
return this.database.all(`SELECT * FROM videos WHERE module_id IS NULL ${orderBy}`);
|
|
58
|
+
}
|
|
59
|
+
return this.database.all(`SELECT * FROM videos WHERE module_id = ? ${orderBy}`, [moduleId]);
|
|
60
|
+
}
|
|
61
|
+
searchVideos(query) {
|
|
62
|
+
const searchTerm = `%${query}%`;
|
|
63
|
+
return this.database.all(`
|
|
64
|
+
SELECT v.*, m.name AS module_name
|
|
65
|
+
FROM videos v
|
|
66
|
+
LEFT JOIN modules m ON v.module_id = m.id
|
|
67
|
+
WHERE v.title LIKE ? OR v.filename LIKE ?
|
|
68
|
+
ORDER BY v.sort_order, v.filename
|
|
69
|
+
`, [searchTerm, searchTerm]);
|
|
70
|
+
}
|
|
71
|
+
getStatsCounts() {
|
|
72
|
+
const total = this.database.get('SELECT COUNT(*) AS count FROM videos')?.count ?? 0;
|
|
73
|
+
const completed = this.database.get("SELECT COUNT(*) AS count FROM videos WHERE status = 'completed'")?.count ?? 0;
|
|
74
|
+
const inProgress = this.database.get("SELECT COUNT(*) AS count FROM videos WHERE status = 'in-progress'")?.count ?? 0;
|
|
75
|
+
return {
|
|
76
|
+
total,
|
|
77
|
+
completed,
|
|
78
|
+
inProgress,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
listAdjacentVideoIds(video) {
|
|
82
|
+
const videos = video.module_id === null
|
|
83
|
+
? this.database.all('SELECT id FROM videos WHERE module_id IS NULL ORDER BY sort_order, filename')
|
|
84
|
+
: this.database.all('SELECT id FROM videos WHERE module_id = ? ORDER BY sort_order, filename', [video.module_id]);
|
|
85
|
+
const currentIndex = videos.findIndex(({ id }) => id === video.id);
|
|
86
|
+
return {
|
|
87
|
+
prev: currentIndex > 0 ? videos[currentIndex - 1].id : null,
|
|
88
|
+
next: currentIndex >= 0 && currentIndex < videos.length - 1 ? videos[currentIndex + 1].id : null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
listQueueVideos(video) {
|
|
92
|
+
if (video.module_id === null) {
|
|
93
|
+
return this.database.all('SELECT * FROM videos WHERE module_id IS NULL ORDER BY sort_order, filename');
|
|
94
|
+
}
|
|
95
|
+
return this.database.all('SELECT * FROM videos WHERE module_id = ? ORDER BY sort_order, filename', [video.module_id]);
|
|
96
|
+
}
|
|
97
|
+
findVideoFiles(dir, relativeTo = dir) {
|
|
98
|
+
const videos = [];
|
|
99
|
+
try {
|
|
100
|
+
const entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const fullPath = node_path_1.default.join(dir, entry.name);
|
|
103
|
+
if (entry.name === app_config_1.appConfig.dbFolder) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (entry.isDirectory()) {
|
|
107
|
+
videos.push(...this.findVideoFiles(fullPath, relativeTo));
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!entry.isFile()) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const extension = node_path_1.default.extname(entry.name).toLowerCase();
|
|
114
|
+
if (!app_config_1.appConfig.videoExtensions.includes(extension)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const relativePath = node_path_1.default.relative(relativeTo, fullPath);
|
|
118
|
+
const parts = relativePath.split(node_path_1.default.sep);
|
|
119
|
+
const moduleName = parts.length > 1 ? parts[0] : 'Root';
|
|
120
|
+
const modulePath = parts.length > 1 ? node_path_1.default.join(relativeTo, parts[0]) : relativeTo;
|
|
121
|
+
videos.push({
|
|
122
|
+
path: fullPath,
|
|
123
|
+
filename: entry.name,
|
|
124
|
+
title: this.extractTitle(entry.name),
|
|
125
|
+
moduleName,
|
|
126
|
+
modulePath,
|
|
127
|
+
sortOrder: this.extractSortOrder(entry.name),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return videos;
|
|
133
|
+
}
|
|
134
|
+
return videos.sort((left, right) => left.sortOrder - right.sortOrder);
|
|
135
|
+
}
|
|
136
|
+
extractTitle(filename) {
|
|
137
|
+
return node_path_1.default.basename(filename, node_path_1.default.extname(filename)).replace(/_/g, ' ').trim() || filename;
|
|
138
|
+
}
|
|
139
|
+
extractSortOrder(value) {
|
|
140
|
+
const match = value.match(/^(\d+)/);
|
|
141
|
+
return match ? Number.parseInt(match[1], 10) : 999;
|
|
142
|
+
}
|
|
143
|
+
getOrCreateModule(modulePath, moduleName) {
|
|
144
|
+
if (moduleName === 'Root') {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const existing = this.database.get('SELECT id FROM modules WHERE path = ?', [modulePath]);
|
|
148
|
+
if (existing) {
|
|
149
|
+
return existing.id;
|
|
150
|
+
}
|
|
151
|
+
const result = this.database.run('INSERT INTO modules (name, path, sort_order) VALUES (?, ?, ?)', [moduleName, modulePath, this.extractSortOrder(moduleName)]);
|
|
152
|
+
return Number(result.lastInsertRowid);
|
|
153
|
+
}
|
|
154
|
+
getVideoOrderBy(sortBy) {
|
|
155
|
+
switch (sortBy) {
|
|
156
|
+
case 'name_desc':
|
|
157
|
+
return 'ORDER BY sort_order DESC, filename DESC';
|
|
158
|
+
case 'date':
|
|
159
|
+
return 'ORDER BY created_at ASC';
|
|
160
|
+
case 'date_desc':
|
|
161
|
+
return 'ORDER BY created_at DESC';
|
|
162
|
+
case 'name':
|
|
163
|
+
default:
|
|
164
|
+
return 'ORDER BY sort_order, filename';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
exports.CatalogRepository = CatalogRepository;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CatalogService = void 0;
|
|
4
|
+
const app_error_1 = require("../../platform/errors/app-error");
|
|
5
|
+
const catalog_mappers_1 = require("./catalog-mappers");
|
|
6
|
+
class CatalogService {
|
|
7
|
+
catalogRepository;
|
|
8
|
+
playbackService;
|
|
9
|
+
notesService;
|
|
10
|
+
constructor(catalogRepository, playbackService, notesService) {
|
|
11
|
+
this.catalogRepository = catalogRepository;
|
|
12
|
+
this.playbackService = playbackService;
|
|
13
|
+
this.notesService = notesService;
|
|
14
|
+
}
|
|
15
|
+
scanVideos() {
|
|
16
|
+
return this.catalogRepository.scanVideos();
|
|
17
|
+
}
|
|
18
|
+
getCatalog(sortBy) {
|
|
19
|
+
const currentSort = (0, catalog_mappers_1.normalizeSort)(sortBy);
|
|
20
|
+
const modules = this.catalogRepository.listModules().map((moduleRow) => (0, catalog_mappers_1.toCatalogModule)(moduleRow, this.catalogRepository
|
|
21
|
+
.listVideosForModule(moduleRow.id, currentSort)
|
|
22
|
+
.map((video) => (0, catalog_mappers_1.toVideoSummary)(video, moduleRow.name))));
|
|
23
|
+
const rootVideos = this.catalogRepository
|
|
24
|
+
.listVideosForModule(null, currentSort)
|
|
25
|
+
.map((video) => (0, catalog_mappers_1.toVideoSummary)(video, null));
|
|
26
|
+
if (rootVideos.length > 0) {
|
|
27
|
+
modules.unshift((0, catalog_mappers_1.toCatalogModule)(null, rootVideos));
|
|
28
|
+
}
|
|
29
|
+
const counts = this.catalogRepository.getStatsCounts();
|
|
30
|
+
return {
|
|
31
|
+
currentSort,
|
|
32
|
+
modules,
|
|
33
|
+
stats: (0, catalog_mappers_1.toCourseStats)(counts.total, counts.completed, counts.inProgress),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
getStats() {
|
|
37
|
+
const counts = this.catalogRepository.getStatsCounts();
|
|
38
|
+
return (0, catalog_mappers_1.toCourseStats)(counts.total, counts.completed, counts.inProgress);
|
|
39
|
+
}
|
|
40
|
+
getPlayerPayload(videoId) {
|
|
41
|
+
const video = this.catalogRepository.findVideoById(videoId);
|
|
42
|
+
if (!video) {
|
|
43
|
+
throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
|
|
44
|
+
}
|
|
45
|
+
const moduleName = this.catalogRepository.findModuleName(video.module_id);
|
|
46
|
+
return {
|
|
47
|
+
video: (0, catalog_mappers_1.toVideoDetails)(video, moduleName),
|
|
48
|
+
adjacent: this.catalogRepository.listAdjacentVideoIds(video),
|
|
49
|
+
notes: this.notesService.getNotes(videoId),
|
|
50
|
+
queue: {
|
|
51
|
+
currentId: video.id,
|
|
52
|
+
moduleName: moduleName ?? 'Videos',
|
|
53
|
+
videos: this.catalogRepository
|
|
54
|
+
.listQueueVideos(video)
|
|
55
|
+
.map((queueVideo) => (0, catalog_mappers_1.toVideoSummary)(queueVideo, moduleName)),
|
|
56
|
+
},
|
|
57
|
+
startPosition: this.playbackService.getPlaybackStartPosition(video),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
searchVideos(query) {
|
|
61
|
+
if (!query) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return this.catalogRepository
|
|
65
|
+
.searchVideos(query)
|
|
66
|
+
.map((video) => (0, catalog_mappers_1.toSearchResult)(video, video.module_name));
|
|
67
|
+
}
|
|
68
|
+
getVideoPath(videoId) {
|
|
69
|
+
const video = this.catalogRepository.findVideoById(videoId);
|
|
70
|
+
if (!video) {
|
|
71
|
+
throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
|
|
72
|
+
}
|
|
73
|
+
return video.path;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
exports.CatalogService = CatalogService;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NotesRepository = void 0;
|
|
4
|
+
class NotesRepository {
|
|
5
|
+
database;
|
|
6
|
+
constructor(database) {
|
|
7
|
+
this.database = database;
|
|
8
|
+
}
|
|
9
|
+
findVideo(videoId) {
|
|
10
|
+
return this.database.get('SELECT id FROM videos WHERE id = ?', [videoId]);
|
|
11
|
+
}
|
|
12
|
+
findNotes(videoId) {
|
|
13
|
+
return this.database.get('SELECT * FROM notes WHERE video_id = ?', [videoId]);
|
|
14
|
+
}
|
|
15
|
+
saveNotes(videoId, content) {
|
|
16
|
+
this.database.run(`
|
|
17
|
+
INSERT INTO notes (video_id, content, updated_at)
|
|
18
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
19
|
+
ON CONFLICT(video_id) DO UPDATE SET
|
|
20
|
+
content = excluded.content,
|
|
21
|
+
updated_at = CURRENT_TIMESTAMP
|
|
22
|
+
`, [videoId, content]);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.NotesRepository = NotesRepository;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NotesService = void 0;
|
|
4
|
+
const app_error_1 = require("../../platform/errors/app-error");
|
|
5
|
+
class NotesService {
|
|
6
|
+
notesRepository;
|
|
7
|
+
constructor(notesRepository) {
|
|
8
|
+
this.notesRepository = notesRepository;
|
|
9
|
+
}
|
|
10
|
+
getNotes(videoId) {
|
|
11
|
+
if (!this.notesRepository.findVideo(videoId)) {
|
|
12
|
+
throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
|
|
13
|
+
}
|
|
14
|
+
const notes = this.notesRepository.findNotes(videoId);
|
|
15
|
+
return {
|
|
16
|
+
videoId,
|
|
17
|
+
content: notes?.content ?? '',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
saveNotes(videoId, content) {
|
|
21
|
+
if (!this.notesRepository.findVideo(videoId)) {
|
|
22
|
+
throw new app_error_1.NotFoundError(`Video with id ${videoId}`);
|
|
23
|
+
}
|
|
24
|
+
this.notesRepository.saveNotes(videoId, content);
|
|
25
|
+
return this.getNotes(videoId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.NotesService = NotesService;
|