atris 3.48.1 → 3.50.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 +397 -1
  2. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  const { apiRequestJson } = require('../utils/api');
2
2
  const { ensureValidCredentials } = require('../utils/auth');
3
3
  const { spawnSync } = require('child_process');
4
+ const fs = require('fs');
4
5
  const path = require('path');
5
6
  const https = require('https');
6
7
 
@@ -25,9 +26,14 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
25
26
  output('');
26
27
  output(`Usage: ${commandName} notes <youtube-url> [engine]`);
27
28
  output(` ${commandName} process <youtube-url> [options]`);
29
+ output(` ${commandName} watch add <channel-url-or-@handle>`);
30
+ output(` ${commandName} watch list`);
31
+ output(` ${commandName} watch remove <number>`);
32
+ output(` ${commandName} watch tick`);
28
33
  output(` ${commandName} <youtube-url> [options]`);
29
34
  output('');
30
35
  output('notes = free local notes, process = 5 credits cloud knowledge');
36
+ output('watch = subscribed channels turn into briefs without a human');
31
37
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
32
38
  output('Falls back to cloud video processing when local captions are unavailable.');
33
39
  output('');
@@ -46,6 +52,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
46
52
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
47
53
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
48
54
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
55
+ output(` ${commandName} watch add @veritasium`);
56
+ output(` ${commandName} watch tick`);
49
57
  output('');
50
58
  }
51
59
 
@@ -81,7 +89,7 @@ function parseYoutubeArgs(argv = []) {
81
89
  return options;
82
90
  }
83
91
 
84
- if (['process', 'analyze', 'watch'].includes(args[0])) {
92
+ if (['process', 'analyze'].includes(args[0])) {
85
93
  args.shift();
86
94
  }
87
95
 
@@ -444,6 +452,370 @@ function formatYoutubeResult(data) {
444
452
  return lines.join('\n');
445
453
  }
446
454
 
455
+ function videoIdFromUrl(url) {
456
+ const text = String(url || '');
457
+ const watch = text.match(/[?&]v=([^&]+)/);
458
+ if (watch) return watch[1];
459
+ const short = text.match(/youtu\.be\/([^?&/]+)/);
460
+ return short ? short[1] : null;
461
+ }
462
+
463
+ function dateStamp(now) {
464
+ if (typeof now === 'string' && /^\d{4}-\d{2}-\d{2}/.test(now)) {
465
+ return now.slice(0, 10);
466
+ }
467
+ const value = now instanceof Date ? now : new Date(now || Date.now());
468
+ if (Number.isNaN(value.getTime())) return new Date().toISOString().slice(0, 10);
469
+ return value.toISOString().slice(0, 10);
470
+ }
471
+
472
+ function firstHeading(notes) {
473
+ const match = String(notes || '').replace(/\r\n/g, '\n').match(/^#{1,6}\s+(.+)$/m);
474
+ return match ? match[1].trim() : '';
475
+ }
476
+
477
+ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
478
+ try {
479
+ const id = videoIdFromUrl(url);
480
+ if (!id) return;
481
+ const notesPath = path.join(workDir, `yt_${id}.md`);
482
+ if (!fs.existsSync(notesPath)) return;
483
+ const notes = fs.readFileSync(notesPath, 'utf8');
484
+ const wikiDir = path.join(cwd, 'atris', 'wiki');
485
+ if (!fs.existsSync(wikiDir)) return;
486
+
487
+ const heading = firstHeading(notes);
488
+ const date = dateStamp(now);
489
+ const header = [
490
+ heading.toLowerCase(),
491
+ '',
492
+ `date: ${date}`,
493
+ `source: ${url}`,
494
+ 'rail: atris youtube notes, quotes repaired against the transcript',
495
+ ].join('\n');
496
+ const briefsDir = path.join(wikiDir, 'briefs');
497
+ fs.mkdirSync(briefsDir, { recursive: true });
498
+ const relBrief = `atris/wiki/briefs/youtube-${id}.md`;
499
+ fs.writeFileSync(path.join(cwd, relBrief), `${header}\n${notes}`);
500
+
501
+ const year = date.slice(0, 4);
502
+ const journalPath = path.join(cwd, 'atris', 'logs', year, `${date}.md`);
503
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
504
+ let existing = '';
505
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
506
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
507
+ const line = `- [claimable] watched: ${heading} -> ${relBrief}`;
508
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
509
+
510
+ console.log(`brief filed: ${relBrief}`);
511
+ } catch {
512
+ // notes filing must never break the youtube command
513
+ }
514
+ }
515
+
516
+ function watchStatePath(cwd = process.cwd()) {
517
+ return path.join(cwd, '.atris', 'state', 'youtube_watch.json');
518
+ }
519
+
520
+ function emptyWatchState() {
521
+ return { channels: [], seen: {}, seeded: {}, seenByChannel: {} };
522
+ }
523
+
524
+ function asObject(value) {
525
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
526
+ }
527
+
528
+ function loadWatchState(statePath) {
529
+ try {
530
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
531
+ const channels = Array.isArray(parsed?.channels) ? parsed.channels : [];
532
+ return {
533
+ channels,
534
+ seen: asObject(parsed?.seen),
535
+ seeded: asObject(parsed?.seeded),
536
+ seenByChannel: asObject(parsed?.seenByChannel),
537
+ };
538
+ } catch {
539
+ return emptyWatchState();
540
+ }
541
+ }
542
+
543
+ function saveWatchState(statePath, state) {
544
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
545
+ const payload = {
546
+ channels: (state.channels || []).map((row) => ({
547
+ channel: row.channel,
548
+ added: row.added,
549
+ })),
550
+ seen: asObject(state.seen),
551
+ seeded: asObject(state.seeded),
552
+ seenByChannel: asObject(state.seenByChannel),
553
+ };
554
+ fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`);
555
+ }
556
+
557
+ function stampNow(now) {
558
+ if (typeof now === 'function') now = now();
559
+ if (typeof now === 'string' && now) return now;
560
+ const value = now instanceof Date ? now : new Date(now || Date.now());
561
+ if (Number.isNaN(value.getTime())) return new Date().toISOString();
562
+ return value.toISOString();
563
+ }
564
+
565
+ function normalizeWatchChannel(input) {
566
+ const raw = String(input || '').trim();
567
+ if (!raw) throw new Error('Missing channel url or @handle. Run "atris youtube watch --help".');
568
+
569
+ let text = raw;
570
+ if (text.startsWith('@')) {
571
+ text = `https://www.youtube.com/${text}`;
572
+ } else if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text)) {
573
+ if (/^(www\.)?youtube\.com\//i.test(text) || /^youtu\.be\//i.test(text)) {
574
+ text = `https://${text}`;
575
+ } else if (/^@?[\w.-]+$/.test(text)) {
576
+ text = `https://www.youtube.com/@${text.replace(/^@/, '')}`;
577
+ } else {
578
+ throw new Error(`Invalid channel: ${raw}`);
579
+ }
580
+ }
581
+
582
+ let parsed;
583
+ try {
584
+ parsed = new URL(text);
585
+ } catch {
586
+ throw new Error(`Invalid channel: ${raw}`);
587
+ }
588
+
589
+ parsed.hash = '';
590
+ parsed.search = '';
591
+ let href = parsed.toString().replace(/\/+$/, '');
592
+ href = href.replace(/\/(videos|featured|streams|shorts)$/i, '');
593
+ return href;
594
+ }
595
+
596
+ function channelVideosUrl(channel) {
597
+ const base = String(channel || '').replace(/\/+$/, '');
598
+ if (/\/videos$/i.test(base)) return base;
599
+ return `${base}/videos`;
600
+ }
601
+
602
+ function parseFlatPlaylist(stdout) {
603
+ const videos = [];
604
+ for (const line of String(stdout || '').split(/\r?\n/)) {
605
+ const trimmed = line.trim();
606
+ if (!trimmed || !trimmed.includes('|')) continue;
607
+ const idx = trimmed.indexOf('|');
608
+ const id = trimmed.slice(0, idx).trim();
609
+ const title = trimmed.slice(idx + 1).trim();
610
+ if (id && id !== 'NA') videos.push({ id, title });
611
+ }
612
+ return videos;
613
+ }
614
+
615
+ function defaultChannelFetcher(videosUrl, deps = {}) {
616
+ const spawn = deps.spawnSync || spawnSync;
617
+ const result = spawn('yt-dlp', [
618
+ '--no-update',
619
+ '--flat-playlist',
620
+ '--playlist-end',
621
+ '3',
622
+ '--print',
623
+ '%(id)s|%(title)s',
624
+ videosUrl,
625
+ ], {
626
+ encoding: 'utf8',
627
+ timeout: 60000,
628
+ maxBuffer: 2 * 1024 * 1024,
629
+ });
630
+ if (result.error || result.status !== 0) {
631
+ const detail = String(result.stderr || result.error?.message || 'fetch failed').trim();
632
+ throw new Error(detail || 'fetch failed');
633
+ }
634
+ return parseFlatPlaylist(result.stdout);
635
+ }
636
+
637
+ function defaultNotesRunner(url, deps = {}) {
638
+ const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
639
+ const spawn = deps.spawnSync || spawnSync;
640
+ return spawn(script, [url], { stdio: 'inherit' });
641
+ }
642
+
643
+ function resolveWatchStatePath(deps = {}) {
644
+ if (deps.statePath) return deps.statePath;
645
+ return watchStatePath(deps.cwd || process.cwd());
646
+ }
647
+
648
+ function channelSeenMap(state, channel) {
649
+ return asObject(asObject(state.seenByChannel)[channel]);
650
+ }
651
+
652
+ function markSeen(state, channel, id, timestamp) {
653
+ if (!state.seen || typeof state.seen !== 'object') state.seen = {};
654
+ if (!state.seenByChannel || typeof state.seenByChannel !== 'object') state.seenByChannel = {};
655
+ if (!state.seenByChannel[channel] || typeof state.seenByChannel[channel] !== 'object') {
656
+ state.seenByChannel[channel] = {};
657
+ }
658
+ state.seen[id] = timestamp;
659
+ state.seenByChannel[channel][id] = timestamp;
660
+ }
661
+
662
+ function addWatchChannel(channelInput, deps = {}) {
663
+ const output = deps.output || ((line = '') => console.log(line));
664
+ if (!channelInput) {
665
+ output('usage: atris youtube watch add <channel-url-or-@handle>');
666
+ return 2;
667
+ }
668
+
669
+ let channel;
670
+ try {
671
+ channel = normalizeWatchChannel(channelInput);
672
+ } catch (err) {
673
+ output(err.message);
674
+ return 2;
675
+ }
676
+
677
+ const statePath = resolveWatchStatePath(deps);
678
+ const state = loadWatchState(statePath);
679
+ if (state.channels.some((row) => row.channel === channel)) {
680
+ output(`already watching ${channel}`);
681
+ return 0;
682
+ }
683
+
684
+ state.channels.push({
685
+ channel,
686
+ added: stampNow(deps.now),
687
+ });
688
+ saveWatchState(statePath, state);
689
+ output(`watching ${channel}`);
690
+ return 0;
691
+ }
692
+
693
+ function listWatchChannels(deps = {}) {
694
+ const output = deps.output || ((line = '') => console.log(line));
695
+ const state = loadWatchState(resolveWatchStatePath(deps));
696
+ if (!state.channels.length) {
697
+ output('no channels watched');
698
+ return 0;
699
+ }
700
+
701
+ state.channels.forEach((row, index) => {
702
+ const count = Object.keys(channelSeenMap(state, row.channel)).length;
703
+ output(`${index + 1}. ${row.channel} (${count} seen)`);
704
+ });
705
+ return 0;
706
+ }
707
+
708
+ function removeWatchChannel(rawNumber, deps = {}) {
709
+ const output = deps.output || ((line = '') => console.log(line));
710
+ const index = Number(rawNumber);
711
+ const statePath = resolveWatchStatePath(deps);
712
+ const state = loadWatchState(statePath);
713
+ if (!Number.isInteger(index) || index < 1 || index > state.channels.length) {
714
+ output('usage: atris youtube watch remove <number>');
715
+ return 2;
716
+ }
717
+
718
+ const [removed] = state.channels.splice(index - 1, 1);
719
+ saveWatchState(statePath, state);
720
+ output(`removed ${removed.channel}`);
721
+ return 0;
722
+ }
723
+
724
+ async function tickWatch(deps = {}) {
725
+ const output = deps.output || ((line = '') => console.log(line));
726
+ const statePath = resolveWatchStatePath(deps);
727
+ const fetcher = deps.fetcher || ((videosUrl) => defaultChannelFetcher(videosUrl, deps));
728
+ const runner = deps.runner || ((url) => defaultNotesRunner(url, deps));
729
+ const briefFiler = deps.briefFiler || fileBriefFromNotes;
730
+ const cwd = deps.cwd || process.cwd();
731
+ const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
732
+ const timestamp = stampNow(deps.now);
733
+ const now = deps.now || timestamp;
734
+
735
+ const state = loadWatchState(statePath);
736
+ let totalNew = 0;
737
+ let totalBriefed = 0;
738
+
739
+ for (const row of state.channels) {
740
+ const videosUrl = channelVideosUrl(row.channel);
741
+ let videos;
742
+ try {
743
+ videos = await Promise.resolve(fetcher(videosUrl, row));
744
+ } catch {
745
+ output(`warning: channel ${row.channel} fetch failed`);
746
+ continue;
747
+ }
748
+ if (!Array.isArray(videos)) {
749
+ output(`warning: channel ${row.channel} fetch failed`);
750
+ continue;
751
+ }
752
+
753
+ const localSeen = channelSeenMap(state, row.channel);
754
+ const isFresh = !state.seeded[row.channel];
755
+ const unseen = videos.filter((video) => video?.id && !localSeen[video.id] && !state.seen[video.id]);
756
+ const newest = videos[0];
757
+ const toBrief = isFresh
758
+ ? (newest?.id ? [newest] : [])
759
+ : unseen;
760
+
761
+ if (isFresh) {
762
+ state.seeded[row.channel] = true;
763
+ for (const video of videos) {
764
+ if (video?.id) markSeen(state, row.channel, video.id, timestamp);
765
+ }
766
+ }
767
+
768
+ let briefed = 0;
769
+ for (const video of toBrief) {
770
+ if (!video?.id) continue;
771
+ const url = `https://www.youtube.com/watch?v=${video.id}`;
772
+ try {
773
+ runner(url, deps);
774
+ } catch {
775
+ // notes failure must not stop the rest of the tick
776
+ }
777
+ try {
778
+ briefFiler({ cwd, url, workDir, now });
779
+ } catch {
780
+ // brief filing must never break the watch tick
781
+ }
782
+ markSeen(state, row.channel, video.id, timestamp);
783
+ briefed += 1;
784
+ }
785
+
786
+ if (!isFresh) {
787
+ for (const video of unseen) {
788
+ if (video?.id) markSeen(state, row.channel, video.id, timestamp);
789
+ }
790
+ }
791
+
792
+ const newCount = isFresh ? (newest?.id ? 1 : 0) : unseen.length;
793
+ output(`channel ${row.channel}: ${newCount} new, ${briefed} briefed`);
794
+ totalNew += newCount;
795
+ totalBriefed += briefed;
796
+ saveWatchState(statePath, state);
797
+ }
798
+
799
+ output(`total: ${totalNew} new, ${totalBriefed} briefed`);
800
+ saveWatchState(statePath, state);
801
+ return 0;
802
+ }
803
+
804
+ async function watchCommand(args = [], deps = {}) {
805
+ const output = deps.output || ((line = '') => console.log(line));
806
+ const sub = args[0];
807
+ if (!sub || ['help', '--help', '-h'].includes(sub)) {
808
+ showYoutubeHelp(output, deps.commandName || 'atris youtube');
809
+ return sub ? 0 : 2;
810
+ }
811
+ if (sub === 'add') return addWatchChannel(args[1], deps);
812
+ if (sub === 'list') return listWatchChannels(deps);
813
+ if (sub === 'remove') return removeWatchChannel(args[1], deps);
814
+ if (sub === 'tick') return tickWatch(deps);
815
+ output(`unknown watch command: ${sub}`);
816
+ return 2;
817
+ }
818
+
447
819
  function runYoutubeNotes(args = [], deps = {}) {
448
820
  const output = deps.output || ((line = '') => console.error(line));
449
821
  const url = args[0];
@@ -459,6 +831,14 @@ function runYoutubeNotes(args = [], deps = {}) {
459
831
  const childArgs = engine ? [url, engine] : [url];
460
832
  const result = spawn(script, childArgs, { stdio: 'inherit' });
461
833
  if (result.status == null) return 1;
834
+ if (result.status === 0) {
835
+ fileBriefFromNotes({
836
+ cwd: deps.cwd || process.cwd(),
837
+ url,
838
+ workDir: deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes'),
839
+ now: deps.now || new Date(),
840
+ });
841
+ }
462
842
  return result.status;
463
843
  }
464
844
 
@@ -469,6 +849,11 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
469
849
  if (!deps.output && !deps.spawnSync) process.exit(code);
470
850
  return code;
471
851
  }
852
+ if (argv[0] === 'watch') {
853
+ const code = await watchCommand(argv.slice(1), { ...deps, output });
854
+ if (!deps.output && !deps.fetcher && !deps.runner && !deps.briefFiler) process.exit(code);
855
+ return code;
856
+ }
472
857
  const options = parseYoutubeArgs(argv);
473
858
  if (options.help) {
474
859
  showYoutubeHelp(output, deps.commandName || 'atris youtube');
@@ -488,5 +873,16 @@ module.exports = {
488
873
  processYoutube,
489
874
  shouldRetryWithLocalTranscript,
490
875
  formatYoutubeResult,
876
+ fileBriefFromNotes,
877
+ normalizeWatchChannel,
878
+ channelVideosUrl,
879
+ parseFlatPlaylist,
880
+ loadWatchState,
881
+ saveWatchState,
882
+ addWatchChannel,
883
+ listWatchChannels,
884
+ removeWatchChannel,
885
+ tickWatch,
886
+ watchCommand,
491
887
  youtubeCommand,
492
888
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.48.1",
3
+ "version": "3.50.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": {