atris 3.49.0 → 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 +326 -1
  2. package/package.json +1 -1
@@ -26,9 +26,14 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
26
26
  output('');
27
27
  output(`Usage: ${commandName} notes <youtube-url> [engine]`);
28
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`);
29
33
  output(` ${commandName} <youtube-url> [options]`);
30
34
  output('');
31
35
  output('notes = free local notes, process = 5 credits cloud knowledge');
36
+ output('watch = subscribed channels turn into briefs without a human');
32
37
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
33
38
  output('Falls back to cloud video processing when local captions are unavailable.');
34
39
  output('');
@@ -47,6 +52,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
47
52
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
48
53
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
49
54
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
55
+ output(` ${commandName} watch add @veritasium`);
56
+ output(` ${commandName} watch tick`);
50
57
  output('');
51
58
  }
52
59
 
@@ -82,7 +89,7 @@ function parseYoutubeArgs(argv = []) {
82
89
  return options;
83
90
  }
84
91
 
85
- if (['process', 'analyze', 'watch'].includes(args[0])) {
92
+ if (['process', 'analyze'].includes(args[0])) {
86
93
  args.shift();
87
94
  }
88
95
 
@@ -506,6 +513,309 @@ function fileBriefFromNotes({ cwd, url, workDir, now } = {}) {
506
513
  }
507
514
  }
508
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
+
509
819
  function runYoutubeNotes(args = [], deps = {}) {
510
820
  const output = deps.output || ((line = '') => console.error(line));
511
821
  const url = args[0];
@@ -539,6 +849,11 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
539
849
  if (!deps.output && !deps.spawnSync) process.exit(code);
540
850
  return code;
541
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
+ }
542
857
  const options = parseYoutubeArgs(argv);
543
858
  if (options.help) {
544
859
  showYoutubeHelp(output, deps.commandName || 'atris youtube');
@@ -559,5 +874,15 @@ module.exports = {
559
874
  shouldRetryWithLocalTranscript,
560
875
  formatYoutubeResult,
561
876
  fileBriefFromNotes,
877
+ normalizeWatchChannel,
878
+ channelVideosUrl,
879
+ parseFlatPlaylist,
880
+ loadWatchState,
881
+ saveWatchState,
882
+ addWatchChannel,
883
+ listWatchChannels,
884
+ removeWatchChannel,
885
+ tickWatch,
886
+ watchCommand,
562
887
  youtubeCommand,
563
888
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.49.0",
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": {