atris 3.51.0 → 3.52.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 (2) hide show
  1. package/commands/youtube.js +208 -17
  2. package/package.json +1 -1
@@ -5,8 +5,9 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const https = require('https');
7
7
 
8
- const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]';
8
+ const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [haiku|atris-fast|gemini|grok|codex|cursor]';
9
9
  const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
10
+ const NOTES_PLAYLIST_CAP = 10;
10
11
 
11
12
  const DEFAULT_QUERY = [
12
13
  'Create a timestamped YouTube brief for Atris.',
@@ -24,7 +25,7 @@ const ALLOWED_CAPTION_HOST_SUFFIXES = [
24
25
 
25
26
  function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
26
27
  output('');
27
- output(`Usage: ${commandName} notes <youtube-url> [engine]`);
28
+ output(`Usage: ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine]`);
28
29
  output(` ${commandName} process <youtube-url> [options]`);
29
30
  output(` ${commandName} digest [--days N]`);
30
31
  output(` ${commandName} watch add <channel-url-or-@handle>`);
@@ -33,7 +34,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
33
34
  output(` ${commandName} watch tick`);
34
35
  output(` ${commandName} <youtube-url> [options]`);
35
36
  output('');
36
- output('notes = free local notes, process = 5 credits cloud knowledge');
37
+ output('notes = free local notes for one url, several urls, or a playlist');
38
+ output('process = 5 credits cloud knowledge');
37
39
  output('digest = one decision page from this week\'s video briefs');
38
40
  output('watch = subscribed channels turn into briefs without a human');
39
41
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
@@ -52,6 +54,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
52
54
  output('');
53
55
  output('Examples:');
54
56
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
57
+ output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID https://youtu.be/OTHER_ID`);
58
+ output(` ${commandName} notes https://www.youtube.com/playlist?list=PLAYLIST_ID`);
55
59
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
56
60
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
57
61
  output(` ${commandName} digest`);
@@ -464,6 +468,34 @@ function videoIdFromUrl(url) {
464
468
  return short ? short[1] : null;
465
469
  }
466
470
 
471
+ function looksLikeYoutubeUrl(arg) {
472
+ const text = String(arg || '').trim();
473
+ if (!text || text.startsWith('-')) return false;
474
+ return /youtube\.com|youtu\.be/i.test(text);
475
+ }
476
+
477
+ function isPlaylistUrl(url) {
478
+ const text = String(url || '');
479
+ return /[?&]list=/.test(text) || /\/playlist(?:\?|$|\/)/i.test(text);
480
+ }
481
+
482
+ function parseNotesArgs(argv = []) {
483
+ const urls = [];
484
+ let engine = null;
485
+ let help = false;
486
+ for (const raw of argv) {
487
+ const arg = String(raw);
488
+ if (arg === '--help' || arg === '-h' || arg === 'help') {
489
+ help = true;
490
+ continue;
491
+ }
492
+ if (arg.startsWith('-')) continue;
493
+ if (looksLikeYoutubeUrl(arg)) urls.push(arg);
494
+ else engine = arg;
495
+ }
496
+ return { urls, engine, help };
497
+ }
498
+
467
499
  function dateStamp(now) {
468
500
  if (typeof now === 'string' && /^\d{4}-\d{2}-\d{2}/.test(now)) {
469
501
  return now.slice(0, 10);
@@ -512,6 +544,7 @@ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
512
544
  fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
513
545
 
514
546
  console.log(`brief filed: ${relBrief}`);
547
+ return relBrief;
515
548
  } catch {
516
549
  // notes filing must never break the youtube command
517
550
  }
@@ -1015,37 +1048,189 @@ async function watchCommand(args = [], deps = {}) {
1015
1048
  return 2;
1016
1049
  }
1017
1050
 
1018
- function runYoutubeNotes(args = [], deps = {}) {
1019
- const output = deps.output || ((line = '') => console.error(line));
1020
- const url = args[0];
1021
- const engine = args[1];
1022
- if (!url) {
1023
- output(YTNOTES_USAGE);
1024
- output(YTNOTES_HINT);
1025
- return 2;
1051
+ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1052
+ const spawn = deps.spawnSync || spawnSync;
1053
+ const result = spawn('yt-dlp', [
1054
+ '--no-update',
1055
+ '--flat-playlist',
1056
+ '--print',
1057
+ '%(id)s|%(title)s',
1058
+ playlistUrl,
1059
+ ], {
1060
+ encoding: 'utf8',
1061
+ timeout: 60000,
1062
+ maxBuffer: 2 * 1024 * 1024,
1063
+ });
1064
+ if (result.error || (result.status != null && result.status !== 0)) {
1065
+ const detail = String(result.stderr || result.error?.message || 'playlist expand failed').trim();
1066
+ throw new Error(detail || 'playlist expand failed');
1026
1067
  }
1068
+ return parseFlatPlaylist(result.stdout);
1069
+ }
1027
1070
 
1071
+ function defaultNotesItemRunner(url, engine, deps = {}) {
1028
1072
  const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
1029
1073
  const spawn = deps.spawnSync || spawnSync;
1030
1074
  const childArgs = engine ? [url, engine] : [url];
1031
- const result = spawn(script, childArgs, { stdio: 'inherit' });
1032
- if (result.status == null) return 1;
1033
- if (result.status === 0) {
1034
- fileBriefFromNotes({
1075
+ return spawn(script, childArgs, { stdio: 'inherit' });
1076
+ }
1077
+
1078
+ function readNowMs(deps = {}) {
1079
+ if (typeof deps.nowMs === 'function') return Number(deps.nowMs()) || 0;
1080
+ if (Number.isFinite(deps.nowMs)) return Number(deps.nowMs);
1081
+ return Date.now();
1082
+ }
1083
+
1084
+ function notesItemLabel(item = {}) {
1085
+ return item.id || item.url || '';
1086
+ }
1087
+
1088
+ function expandNotesTargets(urls = [], deps = {}) {
1089
+ const output = deps.output || ((line = '') => console.error(line));
1090
+ const expander = deps.expander || ((playlistUrl) => defaultPlaylistExpander(playlistUrl, deps));
1091
+ const items = [];
1092
+ for (const url of urls) {
1093
+ if (!isPlaylistUrl(url)) {
1094
+ items.push({ url, id: videoIdFromUrl(url) });
1095
+ continue;
1096
+ }
1097
+ let videos = [];
1098
+ try {
1099
+ videos = expander(url, deps);
1100
+ } catch {
1101
+ items.push({ url, id: videoIdFromUrl(url), failed: true });
1102
+ continue;
1103
+ }
1104
+ if (!Array.isArray(videos) || videos.length === 0) {
1105
+ items.push({ url, id: videoIdFromUrl(url), failed: true });
1106
+ continue;
1107
+ }
1108
+ if (videos.length > NOTES_PLAYLIST_CAP) {
1109
+ output(`playlist capped at ${NOTES_PLAYLIST_CAP} videos (${videos.length} found)`);
1110
+ videos = videos.slice(0, NOTES_PLAYLIST_CAP);
1111
+ }
1112
+ for (const video of videos) {
1113
+ if (!video?.id) continue;
1114
+ items.push({
1115
+ url: `https://www.youtube.com/watch?v=${video.id}`,
1116
+ id: video.id,
1117
+ title: video.title,
1118
+ });
1119
+ }
1120
+ }
1121
+ return items;
1122
+ }
1123
+
1124
+ function invokeNotesRunner(url, engine, deps = {}) {
1125
+ const runner = deps.runner;
1126
+ if (runner) return runner(url, engine, deps);
1127
+ return defaultNotesItemRunner(url, engine, deps);
1128
+ }
1129
+
1130
+ function readRunnerStatus(result) {
1131
+ if (typeof result === 'number') return result;
1132
+ if (result && typeof result === 'object') {
1133
+ return result.status == null ? 1 : result.status;
1134
+ }
1135
+ return 1;
1136
+ }
1137
+
1138
+ function fileNotesBrief(url, deps = {}) {
1139
+ const briefFiler = deps.briefFiler || fileBriefFromNotes;
1140
+ try {
1141
+ const filed = briefFiler({
1035
1142
  cwd: deps.cwd || process.cwd(),
1036
1143
  url,
1037
1144
  workDir: deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes'),
1038
1145
  now: deps.now || new Date(),
1039
1146
  });
1147
+ return typeof filed === 'string' && filed ? filed : null;
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+
1153
+ function runOneNotesItem(item, engine, deps = {}) {
1154
+ const output = deps.output || ((line = '') => console.error(line));
1155
+ const label = notesItemLabel(item);
1156
+ if (item.failed) {
1157
+ output(`${label} 0s FAILED`);
1158
+ return { url: item.url, id: item.id, seconds: 0, ok: false, brief: null };
1159
+ }
1160
+
1161
+ const started = readNowMs(deps);
1162
+ let status = 1;
1163
+ try {
1164
+ status = readRunnerStatus(invokeNotesRunner(item.url, engine, deps));
1165
+ } catch {
1166
+ status = 1;
1167
+ }
1168
+ const brief = status === 0 ? fileNotesBrief(item.url, deps) : null;
1169
+ const seconds = Math.max(0, Math.round((readNowMs(deps) - started) / 1000));
1170
+ const ok = status === 0;
1171
+ output(`${label} ${seconds}s ${ok ? (brief || 'ok') : 'FAILED'}`);
1172
+ return { url: item.url, id: item.id, seconds, ok, brief };
1173
+ }
1174
+
1175
+ function formatNotesSummary(rows = []) {
1176
+ const lines = ['url or id seconds result'];
1177
+ for (const row of rows) {
1178
+ const result = row.ok ? (row.brief || 'ok') : 'FAILED';
1179
+ lines.push(`${notesItemLabel(row)} ${row.seconds}s ${result}`);
1180
+ }
1181
+ return lines.join('\n');
1182
+ }
1183
+
1184
+ function runYoutubeNotesBatch({ urls, engine } = {}, deps = {}) {
1185
+ const output = deps.output || ((line = '') => console.error(line));
1186
+ const items = expandNotesTargets(urls || [], deps);
1187
+ const rows = [];
1188
+ for (const item of items) {
1189
+ rows.push(runOneNotesItem(item, engine, deps));
1190
+ }
1191
+ if (rows.length) {
1192
+ output('');
1193
+ output(formatNotesSummary(rows));
1194
+ }
1195
+ if (!rows.length) return 2;
1196
+ return rows.some((row) => row.ok) ? 0 : 2;
1197
+ }
1198
+
1199
+ function runSingleYoutubeNotes(url, engine, deps = {}) {
1200
+ let result;
1201
+ try {
1202
+ result = invokeNotesRunner(url, engine, deps);
1203
+ } catch {
1204
+ return 1;
1205
+ }
1206
+ const status = readRunnerStatus(result);
1207
+ if (status === 0) fileNotesBrief(url, deps);
1208
+ return status;
1209
+ }
1210
+
1211
+ function runYoutubeNotes(args = [], deps = {}) {
1212
+ const output = deps.output || ((line = '') => console.error(line));
1213
+ const parsed = parseNotesArgs(args);
1214
+ if (parsed.help) {
1215
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
1216
+ return 0;
1217
+ }
1218
+ if (!parsed.urls.length) {
1219
+ output(YTNOTES_USAGE);
1220
+ output(YTNOTES_HINT);
1221
+ return 2;
1222
+ }
1223
+ if (parsed.urls.length === 1 && !isPlaylistUrl(parsed.urls[0])) {
1224
+ return runSingleYoutubeNotes(parsed.urls[0], parsed.engine, deps);
1040
1225
  }
1041
- return result.status;
1226
+ return runYoutubeNotesBatch(parsed, deps);
1042
1227
  }
1043
1228
 
1044
1229
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
1045
1230
  const output = deps.output || ((line = '') => console.log(line));
1046
1231
  if (argv[0] === 'notes') {
1047
1232
  const code = runYoutubeNotes(argv.slice(1), deps);
1048
- if (!deps.output && !deps.spawnSync) process.exit(code);
1233
+ if (!deps.output && !deps.spawnSync && !deps.runner && !deps.expander) process.exit(code);
1049
1234
  return code;
1050
1235
  }
1051
1236
  if (argv[0] === 'digest') {
@@ -1078,6 +1263,12 @@ module.exports = {
1078
1263
  shouldRetryWithLocalTranscript,
1079
1264
  formatYoutubeResult,
1080
1265
  fileBriefFromNotes,
1266
+ looksLikeYoutubeUrl,
1267
+ isPlaylistUrl,
1268
+ parseNotesArgs,
1269
+ expandNotesTargets,
1270
+ runYoutubeNotesBatch,
1271
+ runYoutubeNotes,
1081
1272
  parseDigestArgs,
1082
1273
  collectVideoBriefs,
1083
1274
  buildDigestPrompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.51.0",
3
+ "version": "3.52.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {